Appearance
Packages
Written: 2026-10-14
Learning objectives
- You can pick one Wally package per job instead of hand-rolling.
- You can compare Comm, Janitor, Signal, Ripple, and Promise against their hand-rolled rivals.
- You can judge a package by surface, upkeep, and realm fit.
- You can install with Wally and require through WaitForChild without leaking realms.
- You can spot when a package stops fitting and remove it cleanly.
Prerequisites
Think package first
Reach for a package before writing a helper. A package carries tests and fixes you will not have time to write. A hand-roll carries only tonight. The Wally manager UpliftGames/wally gives you that choice. It works like Cargo and npm. Declare deps in wally.toml. Lock exact versions in wally.lock. Install with one command. The registry lives at wally.run.
Trading control buys upkeep. You accept someone else's API and gain someone else's bug fixes. Full ownership of every line feels pure until cleanup leaks need debugging at midnight. When the same helper appears in a second game, publish or install it instead.
Use one package per job. Networking gets Comm. Cleanup gets Janitor. Same-side events get Signal. Motion gets Ripple. Async orchestration gets Promise. Data gets ProfileStore on the server. Topbar gets TopbarPlus. Entities get jecs. Admin gets Cmdr. These roles match the registries and READMEs: Janitor is a garbage collector (docs, source), Promise is a Promise implementation (source), Cmdr is an extensible command console (source, hooks), ProfileStore saves with session locking and Reconcile (source, API), TopbarPlus builds topbar icons with themes and menus (source), Signal is a Signal class (API), jecs is a shared-realm entity package (source), Comm owns remotes (ServerComm and ClientComm in RbxUtil), Ripple is a motion library for transitions (source). Method names below match those READMEs; check the linked source before calling a new export.
Recommended first
Three packages solve most jobs. Install Comm, Janitor, and Signal in every project. Add the rest only when the job calls for them.
| Package | Job | Why this one |
|---|---|---|
| Comm | Networking | One folder and one build path for every remote. The server binds, the client invokes, and no RemoteEvent names are spelled twice. |
| Janitor | Cleanup | One object owns every connection. One destroy clears them all, including promises tracked with AddPromise. |
| Signal | Same-side events | A plain typed object for module-to-module calls. No BindableEvent instances and no lost types. |
The next four cover one job each. Install them when that job appears.
| Package | Job | Realm | Why this one |
|---|---|---|---|
| Ripple | Motion | shared | Springs and tweens that survive interruption. Rapid presses retarget instead of stacking. |
| Promise | Async orchestration | shared | Concurrent steps with explicit failure. Losers cancel themselves when nobody consumes them. |
| TopbarPlus | Topbar icons | shared | Icons, menus, and themes without hand-built topbar GUIs. Outcomes still go through Comm. |
| jecs | Entity simulation | shared | Queries over per-entity loops, so creature and projectile counts hold up at scale. |
Two packages stay on the server no matter what their manifest claims.
| Package | Job | Why this one |
|---|---|---|
| ProfileStore | Player data | Session locking plus Reconcile, so two servers never own one profile at once. |
| Cmdr | Admin commands | Typed commands with BeforeRun gates instead of custom chat parsers. |
Comm versus hand remotes
Hand remotes drift. RemoteEvents are created in Studio, names are spelled twice, one WaitForChild is forgotten, and connections leak on rewrite. Comm keeps one folder and one build path. The server binds. The client invokes. The remote events guide defines the raw shapes that Comm wraps.
Prefer Comm once you pass two remotes. Prefer it below that too. The third remote always arrives next week.
ModuleScript in ServerScriptService named GiftService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local GiftService = {}
function GiftService.start(janitor: any, serverComm: any): ()
local signal = serverComm:CreateSignal("SendGift") :: any
if signal == nil then
return
end
local connection = signal:Connect(function(player: Player, targetName: unknown): ()
if typeof(targetName) ~= "string" then
return
end
if #targetName < 3 or #targetName > 20 then
return
end
local character = player.Character
if character == nil then
return
end
signal:Fire(player, "Sent")
end)
janitor:Add(connection)
end
return GiftServiceServer calls CreateSignal, Connect, and Fire per the ServerComm and RemoteSignal pages. Janitor call Add per the Janitor docs owns the connection. The handler binds one signal, validates the name length, and checks that the character exists. Expected behavior: valid names echo Sent to the sender, while bad types and short names return silently.
LocalScript in StarterPlayerScripts named GiftController
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local folder = ReplicatedStorage:WaitForChild("GiftComm")
local ClientComm = Comm.ClientComm :: any
local clientComm = ClientComm.new(folder, false, "GiftComm") :: any
local remote = clientComm:BuildObject() :: any
if remote == nil then
return
end
local gift = remote.SendGift :: any
if gift == nil then
return
end
gift:Fire("Mira")Client calls ClientComm.new, BuildObject, and Fire per the ClientComm page. The client builds the object once and guards each step. Expected behavior: one intent reaches the server, and the sender gets one echo.
Janitor versus disconnect tables
Disconnect tables leak. Connections sit in an array, one path is forgotten, and respawns double-disconnect. Janitor tracks everything in one object. Add, then destroy once. The popular listing calls Janitor a garbage collector object for Roblox on wally.run. Its methods new, Add, and Destroy per the Janitor docs are the only surface needed at first.
Prefer Janitor in every Service and Controller that connects anything. Manual tables win only with zero deps and one connection. When a second connection appears, switch.
ModuleScript in ServerScriptService named RoundService
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local RoundService = {}
RoundService.Active = false
function RoundService.start(janitor: any): ()
local joinConn = Players.PlayerAdded:Connect(function(player: Player): ()
if RoundService.Active then
return
end
print(player.Name)
end)
janitor:Add(joinConn)
local leaveConn = Players.PlayerRemoving:Connect(function(player: Player): ()
print(player.Name)
end)
janitor:Add(leaveConn)
end
function RoundService.stop(janitor: any): ()
janitor:Destroy()
RoundService.Active = false
end
return RoundServiceIt adds two player connections to one Janitor and destroys once on stop. Expected behavior: joins print while active, leaves always print, and stop clears both connections without manual bookkeeping.
Signal versus Bindable wrappers
BindableEvents hide intent. Instances are created to call functions, names are wired twice, and types are lost. Signal is a plain object with Connect and Fire. The listing calls it a Signal class on wally.run. Its calls new, Connect, Fire, and Destroy per the Signal API stay same-side only. Never send a Signal across the client server boundary. Use Comm for that.
Prefer Signal for module-to-module calls on one side. Bindables win only when talking to legacy code that already expects an instance. When functions pass through instance attributes, switch to Signal.
ModuleScript in ReplicatedStorage named ScoreBus
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Signal = require(Packages:WaitForChild("Signal")) :: any
local ScoreBus = {}
local changed = Signal.new() :: any
ScoreBus.Changed = changed
function ScoreBus.publish(total: number): ()
if not math.isfinite(total) then
return
end
if total < 0 then
return
end
changed:Fire(math.floor(total))
end
return ScoreBusIt creates one Signal, guards the total, and fires the floored value. Expected behavior: subscribers receive only finite non-negative totals.
ModuleScript in ServerScriptService named ScoreWatcher
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local ScoreBus = require(ReplicatedStorage:WaitForChild("ScoreBus"))
local ScoreWatcher = {}
function ScoreWatcher.start(janitor: any): ()
local connection = ScoreBus.Changed:Connect(function(total: number): ()
if typeof(total) ~= "number" then
return
end
print(total)
end)
janitor:Add(connection)
ScoreBus.publish(10)
end
return ScoreWatcherExpected behavior: start connects once through Janitor and prints 10 from the publish call.
Ripple versus raw tweens
Raw tweens fight interruptions. A TweenService tween starts, the player spams the button, tweens stack, and UI jitters. Ripple models motion as interruptible springs and tweens. The source calls Ripple an elegant motion library for transitions on wally.run. Its builders createTween, createMotion, and onChange per the Ripple source replace most raw tween code.
Default to Ripple for anything interruptible like press, hover, and stagger. Keep raw TweenService for one-shot cutscene tweens Ripple cannot express. State that reason in the commit when you do.
LocalScript in StarterPlayerScripts named PressController
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Ripple = require(Packages:WaitForChild("Ripple")) :: any
local player = Players.LocalPlayer
if player == nil then
return
end
local gui = player:WaitForChild("PlayerGui")
local screen = gui:WaitForChild("MenuScreen")
local card = screen:WaitForChild("Card") :: Frame
local play = screen:WaitForChild("Play") :: TextButton
local motion = Ripple.createTween(0, { duration = 0.25 }) :: any
if motion == nil then
return
end
motion:onChange(function(value: number): ()
card.Position = UDim2.fromScale(0.5, 0.3 + value * 0.05)
end)
play.Activated:Connect(function(): ()
motion:tween(1)
end)Ripple calls createTween, onChange, and motion:tween per the Ripple source drive the card. The handler builds one tween and binds position updates through onChange. Expected behavior: each press eases the card down once, and rapid presses retarget smoothly instead of stacking.
Promise versus spawn chains
Spawn chains hide errors. Nested task.spawn calls mean one step throws, the rest never run, and no caller knows. Promise chains steps with explicit success and failure. The listing calls Promise a Promise implementation for Roblox on wally.run. Its combinators all, any, race, and new per the Promise source orchestrate concurrent work.
Prefer Promise for orchestration like load then preload then start. Keep plain task.spawn for fire-and-forget logs. When a third spawn nests, chain.
ModuleScript in ReplicatedStorage named LoadPlan
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Promise = require(Packages:WaitForChild("Promise")) :: any
local LoadPlan = {}
function LoadPlan.fetchCoins(userId: number): any
return Promise.new(function(resolve: (number) -> (), reject: (string) -> ()): ()
if userId <= 0 then
reject("bad user")
return
end
resolve(100)
end)
end
function LoadPlan.fetchGems(userId: number): any
return Promise.new(function(resolve: (number) -> (), reject: (string) -> ()): ()
if userId <= 0 then
reject("bad user")
return
end
resolve(5)
end)
end
function LoadPlan.loadWallet(userId: number): any
return Promise.all({ LoadPlan.fetchCoins(userId), LoadPlan.fetchGems(userId) })
end
return LoadPlanPromise calls new and all per the Promise source wrap two fetches. The handler rejects bad users and returns one combined promise. Expected behavior: valid users resolve with both balances, while zero or negative ids reject with bad user.
Judge a package before you install
Check three things: surface, upkeep, and realm.
Surface means exports learnable in ten minutes. Read the README. Count entry points. Three is good. Thirty is a framework. Prefer the small one unless the framework is needed. Upkeep means recent fixes and clear ownership. Check dates and issues. Dead code with open breakage costs more than a hand-roll. Realm means shared versus server. Shared packages land in Packages. Server-only packages land in ServerPackages. ProfileStore and Cmdr are server-only even though one manifest may claim shared. Their guides mandate server realm. Follow the guide.
Spend twenty minutes reading before saving two days. Install-first curiosity fills the tree with three signal libs. Review pays off when a junior reads the wally.toml and knows each line's job.
Install with Wally and require safely
Wally reads wally.toml and writes wally.lock. The manifest format with [dependencies], [server-dependencies], and [dev-dependencies] plus shared and server realms is documented in UpliftGames/wally. Run wally install after each edit. Regenerate the sourcemap after. Never vendor package code into src.
File wally.toml
toml
[package]
name = "studio/coin-game"
version = "1.0.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]
Comm = "sleitnick/comm@1.0.0"
Janitor = "howmanysmall/janitor@1.13.13"
Signal = "sleitnick/signal@2.0.3"
Ripple = "littensy/ripple@0.1.0"
Promise = "evaera/promise@3.1.0"
[server-dependencies]
ProfileStore = "lm-loleris/profilestore@1.0.3"
Cmdr = "evaera/cmdr@1.12.0"Version pins mix verified registry paths with illustrative numbers. Resolve exact numbers from wally.lock in the repo, not from memory. The manifest declares shared deps for both sides and server deps for saves and admin. Expected behavior: wally install places shared code under Packages and server code under ServerPackages.
Require through the container first. Then require the child. Keep one alphabetical block. Use :: any only when the package ships without Luau types.
ModuleScript in ReplicatedStorage named DepsCheck
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local Promise = require(Packages:WaitForChild("Promise")) :: any
local Ripple = require(Packages:WaitForChild("Ripple")) :: any
local Signal = require(Packages:WaitForChild("Signal")) :: any
local DepsCheck = {}
function DepsCheck.names(): { string }
local found: { string } = {}
if Comm ~= nil then
table.insert(found, "Comm")
end
if Janitor ~= nil then
table.insert(found, "Janitor")
end
if Promise ~= nil then
table.insert(found, "Promise")
end
if Ripple ~= nil then
table.insert(found, "Ripple")
end
if Signal ~= nil then
table.insert(found, "Signal")
end
return found
end
return DepsCheckIt waits for Packages once and requires each child alphabetically. Expected behavior: names returns all five entries when install succeeded.
Server-only requires look the same but start from ServerScriptService.
ModuleScript in ServerScriptService named ServerDepsCheck
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local Cmdr = require(ServerPackages:WaitForChild("Cmdr")) :: any
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
local ServerDepsCheck = {}
function ServerDepsCheck.ready(): boolean
if Cmdr == nil then
return false
end
if ProfileStore == nil then
return false
end
return true
end
return ServerDepsCheckExpected behavior: ready returns true only when both server packages installed. Confirm Cmdr admin registration in the Cmdr source and ProfileStore session calls in the ProfileStore API before extending this file.
Respect realms
Never require ServerPackages from the client. Never require it from Shared. The client cannot see ServerScriptService. Shared runs on the client too. Either require errors or leaks secrets. Keep ProfileStore and Cmdr behind server files. Mirror display values to the client through Comm or attributes. The client-server boundary guide requires that split.
Confirm realm in two places. wally.toml picks [dependencies] or [server-dependencies]. The project file maps those folders to Packages and ServerPackages. If they disagree, fix both before running.
Common mistakes
Mistake 1: hand-rolling remotes when Comm is installed.
This wrong example wires names twice and skips cleanup.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local event = ReplicatedStorage:WaitForChild("GiftEvent")
event.OnServerEvent:Connect(function(player: Player, note: string): ()
print(player.Name)
print(note)
end)It fails because every new remote repeats the same validation and tracking code. Typos split sender and receiver. Fix it with one ServerComm CreateSignal per the ServerComm page plus Janitor Add per the Janitor docs.
Mistake 2: hand-rolling disconnect tables.
This wrong example forgets one path.
ModuleScript in ServerScriptService named LeakyRounds
luau
--!strict
local Players = game:GetService("Players")
local LeakyRounds = {}
local connections: { RBXScriptConnection } = {}
function LeakyRounds.start(): ()
table.insert(connections, Players.PlayerAdded:Connect(function(player: Player): ()
print(player.Name)
end))
end
return LeakyRoundsIt stores the join connection but never clears it. Restarts stack handlers. Fix it with Janitor new and Destroy per the Janitor docs. One destroy clears every added connection.
ModuleScript in ServerScriptService named FixedRounds
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local FixedRounds = {}
function FixedRounds.start(janitor: any): ()
janitor:Add(Players.PlayerAdded:Connect(function(player: Player): ()
print(player.Name)
end))
end
return FixedRoundsExpected behavior: start tracks the connection, and one Janitor destroy clears it.
Mistake 3: requiring ServerPackages from Shared.
This wrong example pulls a server package into shared code.
ModuleScript in ReplicatedStorage named SharedSave
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
local SharedSave = {}
function SharedSave.ready(): boolean
return ProfileStore ~= nil
end
return SharedSaveIt fails because clients cannot see ServerScriptService. Shared also runs on the client. The require errors on every join. The client-server boundary guide bans this reach. Fix it by moving the require into a server file like ServerDepsCheck and exposing only data through DataService.
Exercises
- Replace a hand-rolled RemoteEvent pair with one Comm signal.
- Replace a disconnect table with one Janitor.
- Move a server-only require out of Shared.
Solution 1
ModuleScript in ServerScriptService named CheerService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local CheerService = {}
function CheerService.start(janitor: any, serverComm: any): ()
local signal = serverComm:CreateSignal("Cheer") :: any
if signal == nil then
return
end
janitor:Add(signal:Connect(function(player: Player): ()
local character = player.Character
if character == nil then
return
end
signal:Fire(player, "Cheered")
end))
end
return CheerServiceExpected behavior: valid cheers echo once to the sender, and Janitor owns the connection. Comm calls match the ServerComm and RemoteSignal pages.
Solution 2
ModuleScript in ServerScriptService named PresenceService
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local PresenceService = {}
function PresenceService.start(janitor: any): ()
janitor:Add(Players.PlayerAdded:Connect(function(player: Player): ()
print(player.Name)
end))
janitor:Add(Players.PlayerRemoving:Connect(function(player: Player): ()
print(player.Name)
end))
end
return PresenceServiceExpected behavior: both connections live in one Janitor and die together.
Solution 3
ModuleScript in ServerScriptService named SaveGate
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
local SaveGate = {}
function SaveGate.store(): any
return ProfileStore
end
return SaveGateModuleScript in ReplicatedStorage named SaveView
luau
--!strict
local SaveView = {
label = "Saves live on the server",
}
table.freeze(SaveView)
return SaveViewExpected behavior: only the server file touches ProfileStore, whose server-only realm is declared in its wally manifest, while Shared holds frozen display text both sides can read.
The manifest that makes builds reproducible
Every Wally project roots at a wally.toml manifest. The [package] table requires name (scope/name, lowercase ASCII plus digits and hyphens), version (SemVer), registry, and realm (shared or server) per the Wally manifest format. Dependency tables decide where code lands: [dependencies] installs into Packages/ and accepts only realm = "shared" packages, [server-dependencies] accepts any realm into server-only trees, and [dev-dependencies] covers test-only tools needed during development. Each entry pins an alias to scope/name@version-requirement, the version is mandatory, never bare scope/name. The resolved graph freezes into wally.lock; commit both files so rebuilds match.
toml
[package]
name = "studio/shop"
version = "1.2.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]
Signal = "sleitnick/signal@1.0.0"
[server-dependencies]
ProfileStore = "lm-loleris/profilestore@1.0.3"
[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"Realm mistakes are the top package bug: requiring a ServerPackages module from a client or shared file breaks replication. Match default.project.json to the tree, keep Packages/ out of hand-edits (regenerate with wally install), and review wally.lock diffs like code.
Summary
Install one package per job. Let Comm own net, Janitor own cleanup, Signal own same-side events, Ripple own motion, and Promise own orchestration. Judge surface, upkeep, and realm before adding. Pin every dependency with a version and commit the lockfile. Require through WaitForChild and keep server packages server-side. Next, harden trust in Security and Production.