---
url: /advanced/packages.md
description: >-
  Prefer maintained packages over hand-rolls, evaluate them, and respect realm
  rules.
---

# Packages

*Written: {{ $frontmatter.date }}*

## 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

* [Architecture](./architecture.md)
* [Rojo Project Structure](../intermediate/rojo-project-structure.md)
* [Remotes and Networking](../roblox-fundamentals/remotes-and-networking.md)

## 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](https://github.com/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](https://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](https://docs.howmanysmall.com/Janitor/api/), [source](https://github.com/howmanysmall/Janitor)), Promise is a Promise implementation ([source](https://github.com/evaera/roblox-lua-promise)), Cmdr is an extensible command console ([source](https://github.com/evaera/Cmdr), [hooks](https://eryn.io/Cmdr/docs/hooks)), ProfileStore saves with session locking and `Reconcile` ([source](https://github.com/MadStudioRoblox/ProfileStore), [API](https://madstudioroblox.github.io/ProfileStore/api/)), TopbarPlus builds topbar icons with themes and menus ([source](https://github.com/1ForeverHD/TopbarPlus)), Signal is a Signal class ([API](https://sleitnick.github.io/RbxUtil/api/Signal/)), jecs is a shared-realm entity package ([source](https://github.com/Ukendio/jecs)), Comm owns remotes ([ServerComm](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [ClientComm](https://sleitnick.github.io/RbxUtil/api/ClientComm) in [RbxUtil](https://github.com/Sleitnick/RbxUtil)), Ripple is a motion library for transitions ([source](https://github.com/littensy/ripple)). 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](https://create.roblox.com/docs/en-us/scripting/events/remote) 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 GiftService
```

Server calls `CreateSignal`, `Connect`, and `Fire` per the [ServerComm](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/RemoteSignal) pages. Janitor call `Add` per the [Janitor docs](https://docs.howmanysmall.com/Janitor/api/) 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](https://sleitnick.github.io/RbxUtil/api/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](https://wally.run). Its methods `new`, `Add`, and `Destroy` per the [Janitor docs](https://docs.howmanysmall.com/Janitor/api/) 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 RoundService
```

It 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](https://wally.run). Its calls `new`, `Connect`, `Fire`, and `Destroy` per the [Signal API](https://sleitnick.github.io/RbxUtil/api/Signal/) 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 ScoreBus
```

It 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 ScoreWatcher
```

Expected 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](https://wally.run). Its builders `createTween`, `createMotion`, and `onChange` per the [Ripple source](https://github.com/littensy/ripple) 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](https://github.com/littensy/ripple) 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](https://wally.run). Its combinators `all`, `any`, `race`, and `new` per the [Promise source](https://github.com/evaera/roblox-lua-promise) 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 LoadPlan
```

Promise calls `new` and `all` per the [Promise source](https://github.com/evaera/roblox-lua-promise) 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](https://github.com/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 DepsCheck
```

It 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 ServerDepsCheck
```

Expected behavior: `ready` returns true only when both server packages installed. Confirm Cmdr admin registration in the [Cmdr source](https://github.com/evaera/Cmdr) and ProfileStore session calls in the [ProfileStore API](https://madstudioroblox.github.io/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://sleitnick.github.io/RbxUtil/api/ServerComm) plus Janitor `Add` per the [Janitor docs](https://docs.howmanysmall.com/Janitor/api/).

**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 LeakyRounds
```

It stores the join connection but never clears it. Restarts stack handlers. Fix it with Janitor `new` and `Destroy` per the [Janitor docs](https://docs.howmanysmall.com/Janitor/api/). 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 FixedRounds
```

Expected 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 SharedSave
```

It fails because clients cannot see ServerScriptService. Shared also runs on the client. The require errors on every join. The [client-server boundary](https://create.roblox.com/docs/en-us/scripting/security/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

1. Replace a hand-rolled RemoteEvent pair with one Comm signal.
2. Replace a disconnect table with one Janitor.
3. Move a server-only require out of Shared.

::: details 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 CheerService
```

Expected behavior: valid cheers echo once to the sender, and Janitor owns the connection. Comm calls match the [ServerComm](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/RemoteSignal) pages.
:::

::: details 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 PresenceService
```

Expected behavior: both connections live in one Janitor and die together.
:::

::: details 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 SaveGate
```

**ModuleScript in ReplicatedStorage named SaveView**

```luau
--!strict
local SaveView = {
	label = "Saves live on the server",
}
table.freeze(SaveView)
return SaveView
```

Expected behavior: only the server file touches ProfileStore, whose server-only realm is declared in its [wally manifest](https://raw.githubusercontent.com/MadStudioRoblox/ProfileStore/main/wally.toml), 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](https://github.com/UpliftGames/wally). 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](./security-and-production.md).
