Skip to content
🌊 You are viewing documentation for the Maelstrom pre-release channel. Pin a stable release for production.

Utilities

Riptide bundles five essential utility modules so you don’t need separate dependencies for the most common patterns:

UtilityAccessPurpose
AsyncRiptide.AsyncTimeouts, retries, parallel execution
SignalRiptide.SignalZero-allocation custom events
TroveRiptide.TroveResource cleanup tracking
EventBusRiptide.EventBusPub/sub messaging between modules
GuardRiptide.GuardRuntime type validation

A coroutine orchestration utility for timeouts, retries, and parallel execution.

type AsyncModule = {
Run: (fn: (...any) -> ...any, timeout: number, ...any) -> ...any,
Retry: (fn: (...any) -> ...any, maxAttempts: number, delay: number?, ...any) -> ...any,
Parallel: (fns: { () -> any }, timeout: number?) -> { any },
}

Riptide.Async.Run(fn, timeout, ...fallback) -> ...any

Executes a (potentially yielding) function with a timeout. If the function does not complete within timeout seconds, the fallback values are returned instead.

-- Wait up to 5 seconds for data, return nil on timeout
local data = Riptide.Async.Run(function()
return DataStoreService:GetAsync("key")
end, 5, nil)

If the function throws an error (before timeout), the error is re-thrown.


Riptide.Async.Retry(fn, maxAttempts, delay?, ...args) -> ...any

Retries a function up to maxAttempts times. If an attempt throws, it waits delay seconds (default 0) before the next attempt. Returns the result on the first success; re-throws the last error if all attempts fail.

ParameterTypeDescription
fnfunctionThe function to retry.
maxAttemptsintegerMaximum attempts (must be ≥ 1).
delaynumber?Seconds between retries (default 0).
...anyArguments passed to fn on each call.
local data = Riptide.Async.Retry(function()
return DataStoreService:GetAsync("player_123")
end, 3, 1) -- up to 3 attempts, 1 second between retries

Riptide.Async.Parallel(fns, timeout?) -> { AsyncResult<any> }

Runs an array of zero-argument functions in parallel via task.spawn and waits for all to complete. Returns explicit result objects in the same order as fns.

type AsyncResult<T> = {
ok: boolean,
value: T?,
error: string?,
timedOut: boolean?,
}
ParameterTypeDescription
fns{ () -> any }Array of functions to run.
timeoutnumber?Max wait time in seconds. Defaults to 30.

If timeout expires before all functions complete, unfinished entries return { ok = false, timedOut = true, error = "..." }.

local results = Riptide.Async.Parallel({
function() return fetchPlayerData() end,
function() return fetchInventory() end,
function() return fetchFriendsList() end,
}, 10)
if results[1].ok then
local data = results[1].value
end

A fast, strictly typed, custom event implementation using a linked-list of connections.

type Connection = {
Connected: boolean,
Disconnect: (self: Connection) -> (),
}
type Signal = {
Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
Once: (self: Signal, fn: (...any) -> ()) -> Connection,
Fire: (self: Signal, ...any) -> (),
Wait: (self: Signal) -> ...any,
DisconnectAll: (self: Signal) -> (),
Destroy: (self: Signal) -> (),
}

Riptide.Signal.new() -> Signal

Creates a new signal instance.

local onScoreChanged = Riptide.Signal.new()

Signal:Connect(fn: (...any) -> ()) -> Connection

Connects a callback to the signal. The callback is invoked synchronously in the caller’s thread whenever Fire is called.

Returns a Connection object.

local connection = onScoreChanged:Connect(function(newScore)
print("Score is now:", newScore)
end)

Signal:Once(fn: (...any) -> ()) -> Connection

Like Connect, but the callback fires only once and then automatically disconnects.

onScoreChanged:Once(function(newScore)
print("First score change:", newScore)
end)

Signal:Fire(...any) -> ()

Fires the signal, calling all connected callbacks synchronously with the provided arguments. Because dispatch is in-thread, no new coroutines are created — this is a zero-allocation hot path.

onScoreChanged:Fire(150)

Signal:Wait() -> ...any

Yields the current thread until the signal is fired. Returns the arguments passed to Fire.

local score = onScoreChanged:Wait()
print("Received score:", score)

Signal:DisconnectAll() -> ()

Disconnects all active connections immediately. Clears all internal references.

onScoreChanged:DisconnectAll()

Signal:Destroy() -> ()

Disconnects all connections and invalidates the signal by removing its metatable. After Destroy, the signal should not be used.

onScoreChanged:Destroy()

Connection:Disconnect() -> ()

Disconnects a single connection from its signal. Safe to call multiple times.

local conn = signal:Connect(function() end)
conn:Disconnect()
print(conn.Connected) -- false

After disconnecting, all internal references (_signal, _fn, _next) are cleared to prevent memory leaks.


A resource cleanup tracking helper based on Sleitnick’s Trove pattern. Exposes scoped tracking for signals, connections, instances, and generic functions.

Access via Riptide.Trove.

Riptide.Trove.new() -> Trove

Creates a new resource tracking Trove instance.

local trove = Riptide.Trove.new()
trove:Add(object: any, cleanupMethod: string?) -> any

Tracks any cleanable object. Automatically infers the cleanup method (checks for :Destroy(), :Disconnect(), or if the object is a function, executes it). A custom cleanup method name can optionally be passed.

-- Track a function
trove:Add(function()
print("Cleaned up function!")
end)
-- Track a custom object
trove:Add(myCustomObject, "CustomCleanup")
trove:Connect(signal: any, callback: (...any) -> ()) -> Connection

Connects a callback to a signal and tracks the resulting connection within the trove.

trove:Connect(game.Players.PlayerAdded, function(player)
print("Player added:", player.Name)
end)
trove:Clean() -> ()

Cleans up all tracked resources in the reverse order that they were added (LIFO) and clears the trove container.

trove:Clean()

A lightweight, isolated pub/sub messenger for inter-module communication. Wrapped in error boundaries so crashing listeners do not block others.

Access via Riptide.EventBus.

Riptide.EventBus.new(label: string?) -> EventBus

Creates a new EventBus instance. label is optional and is used in listener error warnings.

local bus = Riptide.EventBus.new("RoundBus")
bus:On(eventName: string, callback: (...any) -> ()) -> () -> ()

Registers a callback for eventName. Returns an unsubscribe function that disconnects the listener when invoked.

local unsub = bus:On("ScoreUpdated", function(score)
print("New score:", score)
end)
-- Unsubscribe later:
unsub()
bus:Once(eventName: string, callback: (...any) -> ()) -> () -> ()

Registers a callback that runs only on the next emission for eventName, then unsubscribes itself. Returns an unsubscribe function so the one-shot listener can be cancelled before it fires.

local cancel = bus:Once("RoundStarted", function(roundId)
print("First round observed:", roundId)
end)
-- Optional cancellation before the event fires:
cancel()
bus:Emit(eventName: string, ...: any) -> ()

Fires the event, calling all registered listeners synchronously with the provided arguments. Uses xpcall to isolate crashing listeners.

bus:Emit("ScoreUpdated", 1500)
bus:Clear() -> ()

Removes all registered listeners from the EventBus instance.

bus:Clear()
bus:Destroy() -> ()

Cleanup-compatible alias for Clear. This lets an EventBus instance be passed to cleanup utilities that look for Destroy.

trove:Add(bus)

A lightweight, zero-dependency type verification utility library for securing network boundaries and runtime argument validation.

Access via Riptide.Guard.

  • Guard.Number(min: number?, max: number?): Validates a number, supporting optional boundaries (e.g. Guard.Number(0, 100)). Rejects NaN.
  • Guard.String(maxLength: number?): Validates a string, supporting an optional length boundary.
  • Guard.Boolean(): Validates a boolean value (true or false).
  • Guard.Enum(values: { any }): Validates that a value is contained in the whitelist array.
  • Guard.Table(schema: { [string]: Validator }): Validates structured tables against schema mapping rules.
  • Guard.Optional(validator: Validator): Allows the value to be nil or match the nested validator.
  • Guard.Instance(className: string): Validates that the userdata matches the Roblox engine className (supports Lune mocks during tests).
-- Validate a table payload
local isUserSchema = Guard.Table({
name = Guard.String(50),
age = Guard.Number(0, 150),
isAdmin = Guard.Optional(Guard.Boolean()),
})
local ok, err = isUserSchema({ name = "Bob", age = 25 })
if not ok then
warn("Validation error: " .. tostring(err))
end