Appearance
Utility Library Project
Written: 2026-10-14
You build a typed shared library to reuse in later projects.
Learning objectives
You define a Result type, retry fallible calls, limit call rates, format values for display, and test pure modules with asserts.
Prerequisites
You need modules and strict basics. Read modules and require, annotations and inference, errors and defensive code, and testing.
Specification
You create four shared modules and one Studio test script.
Module layout:
ReplicatedStorage/Shared/Resulthandles success or failure values.ReplicatedStorage/Shared/Retryreruns a fallible function.ReplicatedStorage/Shared/RateLimiterallows or denies an action per key.ReplicatedStorage/Shared/Formatformats coins and time.ServerScriptService/UtilityTestruns asserts in Play.
Acceptance:
- Each module opens with
--!strict. - Each module returns one table.
- Shared modules freeze their return table.
- Shared modules fetch no services.
- Test script asserts ok paths and failure paths.
- Test script prints
UtilityTest passedwhen all checks hold.
The server stays authoritative when you reuse this library, even when shared code runs on both sides. See server authority and authority techniques.
Architecture
Shared stays pure, with effects at the edge. Result carries outcomes. Retry calls Result producers. RateLimiter guards remotes. Format shapes display strings. Test requires each module and checks behavior. No module requires another module except Test, so dependencies stay flat with no cycles. You can copy this folder into any later project.
Implementation
Follow these steps in order.
- Create a folder
SharedunderReplicatedStorage. - Add
Resultas a ModuleScript underShared. - Add
Retryas a ModuleScript underShared. - Add
RateLimiteras a ModuleScript underShared. - Add
Formatas a ModuleScript underShared. - Add
UtilityTestas a Script underServerScriptService. - Press Play and read the Output window.
Container: ReplicatedStorage/Shared/Result as a ModuleScript. Behavior: you wrap a value or an error in one table. Expect Play to show no output from this module alone.
luau
--!strict
export type Result<T, E> = {
ok: boolean,
value: T?,
error: E?,
}
local Result = {}
function Result.ok<T>(value: T): Result<T, string>
return { ok = true, value = value, error = nil }
end
function Result.err<E>(message: E): Result<string, E>
return { ok = false, value = nil, error = message }
end
function Result.unwrap<T, E>(result: Result<T, E>): T
if result.ok then
local value: T? = result.value
assert(value ~= nil, "missing value")
return value :: T
end
local message: string = tostring(result.error)
error(message)
end
function Result.isOk<T, E>(result: Result<T, E>): boolean
if result.ok then
return true
end
return false
end
return table.freeze(Result)Prose walkthrough: you call ok for a value and err for a failure. Use unwrap only when you accept a throw on misuse, and isOk when you branch without throwing.
Container: ReplicatedStorage/Shared/Retry as a ModuleScript. Behavior: you run a function up to N times with a wait between tries. Expect Play to retry twice then return the third result.
luau
--!strict
local Retry = {}
function Retry.run(operation: () -> (boolean, string), attempts: number, waitSeconds: number): (boolean, string)
local count: number = attempts
if count < 1 then
count = 1
end
local waitTime: number = waitSeconds
if waitTime < 0 then
waitTime = 0
end
local lastError: string = "no attempts"
local index: number = 1
while index <= count do
local ok: boolean, value: string = operation()
if ok then
return true, value
end
lastError = value
if index < count and waitTime > 0 then
task.wait(waitTime)
end
index += 1
end
return false, lastError
end
return table.freeze(Retry)Prose walkthrough: you pass a function that returns true plus a value or false plus an error, with the max tries and the pause. It returns true on the first success, or false with the last error when all tries fail.
Container: ReplicatedStorage/Shared/RateLimiter as a ModuleScript. Behavior: you allow N actions per window per key. Expect rapid calls after the limit to return false until the window passes.
luau
--!strict
export type Limiter = {
limit: number,
window: number,
hits: { [string]: { [number]: number } },
}
local RateLimiter = {}
function RateLimiter.new(limit: number, window: number): Limiter
local safeLimit: number = limit
if safeLimit < 1 then
safeLimit = 1
end
local safeWindow: number = window
if safeWindow <= 0 then
safeWindow = 1
end
return { limit = safeLimit, window = safeWindow, hits = {} }
end
function RateLimiter.allow(limiter: Limiter, key: string): boolean
local now: number = os.clock()
local bucket: { [number]: number }? = limiter.hits[key]
if bucket == nil then
bucket = {}
limiter.hits[key] = bucket
end
local active: { [number]: number } = bucket :: { [number]: number }
local fresh: { [number]: number } = {}
for _, moment in active do
if now - moment < limiter.window then
table.insert(fresh, moment)
end
end
limiter.hits[key] = fresh
if #fresh >= limiter.limit then
return false
end
table.insert(fresh, now)
return true
end
function RateLimiter.clear(limiter: Limiter, key: string): ()
limiter.hits[key] = nil
end
return table.freeze(RateLimiter)Prose walkthrough: you create one limiter with a count and a window in seconds, then call allow with a player id or action name. It stores only timestamps inside the window. Clear a key when you reset a test.
Container: ReplicatedStorage/Shared/Format as a ModuleScript. Behavior: you turn numbers into short display strings. Expect 1500 to show as 1.5K and 90 seconds to show as 01:30.
luau
--!strict
local Format = {}
function Format.coins(amount: number): string
local value: number = amount
if value < 0 then
value = 0
end
if value >= 1_000_000 then
return `{value // 1_000_000}.{(value // 100_000) % 10}M`
end
if value >= 10_000 then
return `{value // 1_000}K`
end
if value >= 1_000 then
return `{value // 1_000}.{(value // 100) % 10}K`
end
return tostring(math.floor(value))
end
function Format.clock(totalSeconds: number): string
local total: number = math.floor(totalSeconds)
if total < 0 then
total = 0
end
local minutes: number = total // 60
local seconds: number = total % 60
local minuteText: string = tostring(minutes)
if minutes < 10 then
minuteText = `0{minutes}`
end
local secondText: string = tostring(seconds)
if seconds < 10 then
secondText = `0{seconds}`
end
return `{minuteText}:{secondText}`
end
return table.freeze(Format)Prose walkthrough: call coins for wallet labels and clock for timers. Pass plain numbers to get short stable strings.
Container: ServerScriptService/UtilityTest as a Script. Behavior: you require all four modules and assert core paths. Expect Output to show UtilityTest passed on a clean run.
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Result = require(Shared:WaitForChild("Result"))
local Retry = require(Shared:WaitForChild("Retry"))
local RateLimiter = require(Shared:WaitForChild("RateLimiter"))
local Format = require(Shared:WaitForChild("Format"))
local okResult = Result.ok("coin")
assert(Result.isOk(okResult), "ok flag")
assert(Result.unwrap(okResult) == "coin", "unwrap value")
local errResult = Result.err("full")
assert(not Result.isOk(errResult), "err flag")
local tries: number = 0
local function flaky(): (boolean, string)
tries += 1
if tries < 3 then
return false, "busy"
end
return true, "done"
end
local worked: boolean, text: string = Retry.run(flaky, 4, 0)
assert(worked, "retry success")
assert(text == "done", "retry value")
assert(tries == 3, "retry count")
local limiter = RateLimiter.new(2, 60)
assert(RateLimiter.allow(limiter, "shop"), "first allow")
assert(RateLimiter.allow(limiter, "shop"), "second allow")
assert(not RateLimiter.allow(limiter, "shop"), "third denied")
RateLimiter.clear(limiter, "shop")
assert(RateLimiter.allow(limiter, "shop"), "allow after clear")
assert(Format.coins(1500) == "1.5K", "coin format")
assert(Format.coins(42) == "42", "small coin format")
assert(Format.clock(90) == "01:30", "clock format")
print("UtilityTest passed")Prose walkthrough: run this in Play only. Check Result flags first, retry counts next, limiter allow then deny then allow, and format strings last. Read the final print to confirm success.
Testing it
- Press Play in Studio.
- Open View then Output.
- Confirm
UtilityTest passedappears once. - Change
RateLimiter.new(2, 60)toRateLimiter.new(1, 60)and press Play again. - Confirm the second
allowassert fails. - Restore the limit to 2.
- Press Play again and confirm the pass message returns.
Test pure logic before you wire gameplay, and keep this test file in the project.
Common mistakes and failures
You return two values where one table is expected. Fix the return shape. You compare booleans with == true. Use truthiness instead. You skip nil checks on result.value. Assert before you return. You store limiter state in a local that resets each call. Keep the limiter table outside the function. You format with string concatenation on numbers. Convert with tostring first. You require modules by dynamic paths. Use static WaitForChild names.
Exercises and extensions
- Add
Result.mapthat transforms the value when ok and passes errors through. - Add
Retry.runResultthat returns a Result table instead of two values. - Add
RateLimiter.countthat returns the fresh hit count for a key. - Add
Format.percentthat clamps 0 to 1 and returns42%style text. - Copy this Shared folder into gameplay mechanic and require Result in the coin grant path.
Solution 1
Add map beside ok/err in Shared/Result. It applies fn only when ok holds and passes errors through untouched.
ModuleScript in ReplicatedStorage/Shared named ResultMap
luau
--!strict
export type Result<T, E> = {
ok: boolean,
value: T?,
error: E?,
}
local ResultMap = {}
function ResultMap.map<T, U, E>(result: Result<T, E>, fn: (T) -> U): Result<U, E>
if not result.ok then
return { ok = false, value = nil, error = result.error }
end
local value: T? = result.value
assert(value ~= nil, "missing value")
return { ok = true, value = fn(value :: T), error = nil }
end
return table.freeze(ResultMap)Expected behavior: mapping over ok transforms the value, while mapping over err returns the same error without calling fn.
Solution 2
Wrap Retry.run so callers get one table instead of two values.
ModuleScript in ReplicatedStorage/Shared named RetryResult
luau
--!strict
local RetryResult = {}
function RetryResult.runResult(operation: () -> (boolean, string), attempts: number, waitSeconds: number): { ok: boolean, value: string? }
local count: number = attempts
if count < 1 then
count = 1
end
local tries: number = 0
local lastError: string = "no attempts"
while tries < count do
tries += 1
local ok: boolean, value: string = operation()
if ok then
return { ok = true, value = value }
end
lastError = value
if tries < count and waitSeconds > 0 then
task.wait(waitSeconds)
end
end
return { ok = false, value = lastError }
end
return table.freeze(RetryResult)Expected behavior: success returns { ok = true } with the value, while exhausted tries return { ok = false } with the last error.
Solution 3
Count fresh hits without recording a new one.
ModuleScript in ReplicatedStorage/Shared named LimiterCount
luau
--!strict
export type Limiter = {
limit: number,
window: number,
hits: { [string]: { [number]: number } },
}
local LimiterCount = {}
function LimiterCount.count(limiter: Limiter, key: string): number
local now: number = os.clock()
local bucket: { [number]: number }? = limiter.hits[key]
if bucket == nil then
return 0
end
local fresh: number = 0
for _, moment in bucket :: { [number]: number } do
if now - moment < limiter.window then
fresh += 1
end
end
return fresh
end
return table.freeze(LimiterCount)Expected behavior: count returns 2 after two allows inside the window and 0 for an unknown key, without changing the bucket.
Solution 4
Clamp first, then format with a percent sign.
ModuleScript in ReplicatedStorage/Shared named FormatPercent
luau
--!strict
local FormatPercent = {}
function FormatPercent.percent(ratio: number): string
local value: number = ratio
if not math.isfinite(value) then
value = 0
end
if value < 0 then
value = 0
end
if value > 1 then
value = 1
end
return `{math.floor(value * 100)}%`
end
return table.freeze(FormatPercent)Expected behavior: percent(0.42) returns 42%, negatives return 0%, values above 1 return 100%, and NaN returns 0%.
Solution 5
Return a Result from the claim path so callers handle grants and rejections one way.
Script in ServerScriptService named ClaimWithResult
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Result = require(Shared:WaitForChild("Result"))
local function grantCoin(balance: number): { ok: boolean, value: number?, error: string? }
if not math.isfinite(balance) then
return Result.err("bad-balance")
end
if balance < 0 then
return Result.err("bad-balance")
end
return Result.ok(math.floor(balance) + 1)
end
local granted = grantCoin(4)
assert(Result.isOk(granted), "grant ok")
assert(Result.unwrap(granted) == 5, "grant value")
local rejected = grantCoin(0 / 0)
assert(not Result.isOk(rejected), "reject nan")
print("ClaimWithResult passed")Expected behavior: Output prints ClaimWithResult passed because valid balances grant and NaN rejects through the same Result shape.
Summary
You built four typed helpers and tested them with asserts. Next read gameplay mechanic.