tohue/loom

custom framework which is using modern luau types and ByteNet for the insane traffic speed

Loom

A fast networking and lifecycle framework for Roblox, written in strictly typed Luau. Loom organises a game into services (server), controllers (client) and components (tagged instances), and connects them with typed packets built on ByteNet.

Packets are serialised by ByteNet into buffers. Everything sent to the same destination during a frame is batched into one buffer and one remote call on Heartbeat: a thousand sendToAll calls in a frame produce a single FireAllClients. Payload types are inferred from the schema, and each side sees only the packet functions it can call.

Requirements

  • Luau's new type solver (LuauSolverV2). Loom's types use read properties, type functions and other features the old solver rejects.
    • Zed: lsp.luau-lsp.settings.fflags.enable_new_solver = true
    • VS Code: luau-lsp.fflags.enableNewSolver = true
    • CLI: luau-lsp analyze --flag:LuauSolverV2=true
  • In editors that apply only Roblox's synced flags, also enable LuauOverloadGetsInstantiated2. Without it, generic calls such as Loom.Packet({ Schema = ... }) reject tables that omit optional fields.

Installation

With pesde:

pesde add tohue/loom
pesde install

With Wally, add Loom to wally.toml and run wally install:

[dependencies]
loom = "tohue/[email protected]"

The examples below use the alias loom, so require(Packages.loom) works with either. Wally's generated Packages/loom.lua returns the module but drops its exported types; run wally-package-types after each install (wally-package-types --sourcemap sourcemap.json Packages/) to get Loom.Service<M, N> and the other types back.

Structure

PieceSidePurpose
DefinitionsharedLoom.Define: a name, a start priority and the packets. Required by both sides.
ServiceserverA woven definition with server logic.
ControllerclientA woven definition with client logic. Shares the service's definition, and with it the packets.
ComponenteitherA class bound to every instance with a CollectionService tag.
PacketsharedA typed ByteNet packet inside a definition's Net table.

Example

A shared definition:

-- ReplicatedStorage/Shared/Schemas/ShopService.luau
local Loom = require(Packages.loom)
local ByteNet = Loom.Util.ByteNet

return Loom.Define({
	Name = "ShopService",
	Net = {
		Bought = Loom.Packet({
			Schema = ByteNet.struct({ item = ByteNet.string, price = ByteNet.uint16 }),
			RateLimit = { Requests = 5, Window = 1 },
		}),
	},
})

The typed contract, one type per service:

-- ServerScriptService/ServerAPI.luau
local Loom = require(Packages.loom)
local ShopServiceDef = require(ReplicatedStorage.Shared.Schemas.ShopService)

export type ShopService = Loom.Service<{
	Buy: (self: ShopService, player: Player, item: string) -> (),
}, typeof(ShopServiceDef.Net)>

return {
	ShopService = ShopServiceDef :: ShopService,
}

The implementation:

-- ServerScriptService/Services/ShopService.luau
local ShopService = Loom.Weave(API.ShopService)

function ShopService:LoomInit()
	self.Net.Bought.listen(function(data, player)
		self.Debugger:Debug("%s bought %s", player.Name, data.item)
	end)
end

function ShopService:Buy(player: Player, item: string)
	self.Net.Bought.sendTo({ item = item, price = 100 }, player)
end

return ShopService

The loader, once per side:

for _, module in Services:GetDescendants() do
	if module:IsA("ModuleScript") then
		require(module)
	end
end

Loom.Spin()

API

Loom.Define

Loom.Define<D>(definition: D & { read Name: string }): D

Declares a service or controller. The definition is returned unchanged and typed exactly as written, so typeof(Def.Net) is its packet table type.

FieldTypeDescription
NamestringUnique name. Also the ByteNet namespace of its packets.
Prioritynumber?Start order; higher starts first. Default 0.
Net{ [string]: Packet }?The packets. Frozen by Define.

Loom.Packet

Loom.Packet<T>(definition: PacketDef<T>): Packet<T>

Creates a packet. T is inferred from Schema.

FieldTypeDescription
SchemaTBuilt from Loom.Util.ByteNet.
ReliabilityType"reliable" | "unreliable"Default "reliable". Use Loom.Enums.Reliability.
Middleware{ (player, data) -> boolean }?Server-side checks for client packets, run in order. Returning false drops the packet.
RateLimit{ Requests, Window } | false | nilPer-player limit for client packets. nil uses LoomConfig.Net.RateLimit; false disables it for this packet.

A packet's members depend on the side. A service's Net exposes the server members and a controller's Net exposes the client members; calling the other side's member is a type error.

MemberSideDescription
sendToAll(data)serverSends to every player.
sendTo(data, player)serverSends to one player.
sendToList(data, players)serverSends to each player in the list.
sendToAllExcept(data, player)serverSends to every player but one.
send(data)clientSends to the server.
listen(callback)bothcallback(data, player); player is the sender on the server and nil on the client. Returns a connection with Disconnect.
wait()bothYields until the next packet and returns data, player.

listen and wait work immediately. The send members work after Loom.Spin has connected the owning service or controller.

On the server, incoming packets pass their checks in this order: the rate limit, the NaN/inf check (LoomConfig.Security.RejectNonFinite), LoomConfig.Net.Middleware, then the packet's own Middleware. Packets from a player that Security has blocked are dropped before any of them.

Loom.Weave

Loom.Weave<S>(service: S): S

Registers a service (server) or controller (client) for Loom.Spin and returns the same table. Loom adds:

FieldTypeDescription
DebuggerDebuggerLogger named after the service. See Debugger.
TroveTroveCleanup container.
Prioritynumber0 when the definition omits it.

Optional lifecycle methods: LoomInit(self) and LoomStart(self).

Loom.Spin

Loom.Spin(): TypedPromise<()>

Starts every woven service or controller. Call it once, after requiring all of their modules.

  1. Connects every Net. On the client, waits for the server to publish each namespace (LoomConfig.Net.NamespaceTimeout).
  2. Runs every LoomInit concurrently and waits for all of them. A LoomInit may yield.
  3. Spawns LoomStart for each successfully initialised entry, highest Priority first.

A failure in any step is logged with a traceback and excludes only the failing service. A LoomInit still running after LoomConfig.Spin.SlowInitWarning seconds produces a warning.

Loom.Component

Loom.Component<S>(definition: S): S

Creates a component class based on sleitnick's Component. Define methods on the returned class.

local Lamp = Loom.Component({ Tag = "Lamp" } :: API.LampComponent)

function Lamp:LoomStart()
	self.Instance.Light.Enabled = true
end

return Lamp

Definition fields:

FieldDescription
TagCollectionService tag the class binds to.
AncestorsInstances under which tagged instances count. Default { workspace, Players }.
ExtensionsComponent extensions.
DecoratorsMethod name to a list of decorators. The "*" key decorates every method except lifecycle hooks and per-frame updates.
RenderPriorityRender step priority for RenderSteppedUpdate.

Other fields become class members shared by every instance.

Lifecycle methods: LoomInit, LoomStart, LoomDestroy, HeartbeatUpdate(dt), SteppedUpdate(dt), RenderSteppedUpdate(dt). Each instance receives LoomDestroy and a cleaned Trove exactly once, including instances removed before they start.

Instance fields: Instance, Trove, Debugger, Util (GetPlayerFromModel, GetPlayerFromPart, IsAlive) and GetComponent(class).

Class members: Started, Stopped, GetAll(), FromInstance(instance), WaitForInstance(instance, timeout?).

Loom.Decorate

Loom.Decorate<S>(target: S, method: keyof<S>, ...: Decorator)

Wraps an existing method of a service, controller or component class. The first decorator is the outermost. method is type-checked against the target's keys.

Loom.Decorate(ShopService, "Buy", Decorators.Cooldown(1, function(player) return player end))

A decorator is a function (self, callback, ...) -> .... It calls callback(self, ...) to run the method, or returns without calling it to block the call.

Loom.Decorators

DecoratorDescription
CanCall(predicate)Runs the method only when predicate(self, ...) returns true.
Compose(...)Combines several decorators into one, in the given order.
Cooldown(seconds, keyOf?)At most one call per seconds, per object, or per key returned by keyOf(...).
DebounceIgnores calls while the method is already running on the same object.
IsBusy(flag)Blocks the method while self[flag] is true or nil.
IsPlayer(aliveOnly?)For touch handlers: runs only when the first argument belongs to a player, and passes that player after it.
OnceRuns the method once per object.
Profile(label)Labels each call in the MicroProfiler. For methods that do not yield.
SafeCatches and warns about errors instead of throwing.
SpawnRuns the method in a new thread and returns immediately.

Loom.Middleware

MiddlewareDescription
IsAliveDrops packets from players without a living character.
RateLimit(requests, window)At most requests packets per player per window seconds. Prefer the packet's RateLimit field.
FiniteDrops packets with NaN or ±inf anywhere in them: numbers, Vector2, Vector3, CFrame, Color3, and table keys and values. LoomConfig.Security.RejectNonFinite applies it to every packet.

Custom middleware is any (player: Player, data) -> boolean. Declare both parameters even when data is unused; a one-parameter function weakens the inferred payload type.

Loom.Util

FieldDescription
ByteNetSchema builders: struct, array, optional, map, and the data types bool, string, buff, inst, cframe, vec2, vec3, uint8, uint16, uint32, int8, int16, int32, float32, float64, nothing, unknown. Each type is typed as the value it carries, so ByteNet.struct({ hp = ByteNet.uint8 }) is { hp: number }.
DebuggerDebugger.new(name) creates a Debugger outside a service.
Promiseevaera's Promise, typed.
Signalsleitnick's Signal.
Trovesleitnick's Trove.
Siftcsqrl's Sift.

Loom.Enums.Reliability holds Reliable and Unreliable. Loom.IS_SERVER and Loom.IS_STUDIO are booleans.

Debugger

Every service, controller and component instance has self.Debugger.

MethodOutput
Trace(message, ...)print
Debug(message, ...)print
Info(message, ...)print
Warn(message, ...)warn
Error(message, ...)warn with a traceback; does not throw
IsEnabled(level)Whether a log at level is written

With extra arguments, message is a string.format pattern, formatted only when the level is enabled. Use %* for values that are not strings or numbers. Each line names the owner and the calling function:

[Debug] ShopService:Buy: Tohue bought Sword
[Trace] Lamp[Lamp1]:LoomStart: enabled

Configuration

Loom reads an optional ReplicatedStorage.LoomConfig module. Every field is optional; omitted fields keep their defaults. Unknown keys and wrong types raise an error.

local RunService = game:GetService("RunService")
local IS_STUDIO = RunService:IsStudio()

local Loom = require(ReplicatedStorage.Packages.loom)

local config: Loom.LoomConfig = {
	Log = {
		Level = if IS_STUDIO then "Debug" else "Warn",
		Overrides = { ShopService = "Trace" },
	},
	Net = {
		RateLimit = { Requests = 30, Window = 1 },
		LogDrops = IS_STUDIO,
	},
}

return config
FieldDefaultDescription
Log.Level"Debug" in Studio, "Warn" liveOne of Trace, Debug, Info, Warn, Error, None.
Log.OverridesnoneLevel per service/controller Name or component Tag. Loom sets Loom's own messages.
Net.RateLimitoffPer-player limit for every client packet without its own RateLimit.
Net.MiddlewarenoneChecks for every client packet, before each packet's own Middleware.
Net.LogDropsfalseWarns about each dropped packet with the packet, the player and the check.
Net.NamespaceTimeout30Seconds a client waits for the server to publish a namespace.
Spin.SlowInitWarning10Seconds before a running LoomInit produces a warning.

The module is read the first time Loom needs a setting, so it may require Loom.

Security

LoomConfig.Security protects the server from what clients send. Every part is off by default and works on its own. A client controls everything it sends, so none of this replaces checking requests in your services (does the player own that item, can they reach that position); it handles the generic cases.

Security = {
	RejectNonFinite = true,
	Traffic = { Calls = 120, Bytes = 64 * 1024, Window = 1 },
	Strikes = { Limit = 20, Window = 10, Action = "Kick" },
	OnViolation = function(player, violation)
		warn(player.Name, violation.Kind, violation.Packet, violation.Reason)
	end,
},
FieldDefaultDescription
Security.RejectNonFinitefalseDrops client packets with NaN or ±inf anywhere in them. ByteNet's float types decode any bits a client sends, and NaN gets past checks like if damage > 100 because every comparison with it is false.
Security.Trafficoff{ Calls?, Bytes?, Window }: a per-player budget for every call to ByteNet's remotes, counted before ByteNet decodes anything, so malformed buffers count too. A player over budget has every packet dropped until the window ends.
Security.Strikesoff{ Limit, Window, Action, BlockTime?, KickMessage? }: after Limit violations within Window seconds, "Kick" kicks the player and "Block" drops all their packets for BlockTime seconds (default 30).
Security.OnViolationnone(player, violation), called for every violation in its own thread. violation.Kind is "Drop" (a check dropped a packet; violation.Packet names it), "Traffic" (over budget) or "Payload" (a remote call that isn't a ByteNet buffer, which the real client never sends).

Every dropped packet counts as a violation, rate limit drops included. Set Strikes.Limit high enough that a laggy client releasing a burst of queued packets doesn't get kicked. Loom logs traffic blocks, strike blocks and kicks at Warn under the name Loom.

Loom's remote listener runs after ByteNet's, so the call that crosses the traffic budget still gets decoded; the block starts with the next one. ByteNet errors on a buffer it can't decode, which drops the rest of that call and prints an error in the server output. Loom can't catch that without forking ByteNet, but Traffic limits how often a client can cause it.

Types

require(Packages.loom) exports every public type, e.g. Loom.Service<M, N>. They are defined in Loom's Types module.

TypeDescription
Service<M, N>A service: members M, packet table N (usually typeof(Def.Net)).
Controller<M, N>A controller: members M, packet table N.
Component<T, I>A component class with members T on instances of class I.
ComponentInstance<T, I>self inside component methods.
Packet<T>, PacketDef<T>A packet and its definition.
ServerNet<N>, ClientNet<N>A packet table as each side sees it.
Middleware<T>, RateLimitConfigPacket checks.
Decorator<Self>, AnyDecoratorDecorators.
Debugger, LogLevelLogging.
LoomConfigThe configuration module's shape.
TrafficConfig, StrikesConfig, ViolationLoomConfig.Security parts and what OnViolation receives.
ByteNetThe schema builder table.
Signal<T...>, Connection, Trove, Promise, TypedPromise<T...>Vendor types.

License

MIT