Skip to content

Write a controller

A controller is a client-side ModuleScript that returns a table. It is the same shape as a service, booted by the same Lifecycle, and the two words exist only so it is always clear which side a module runs on.

ReplicatedStorage/Client/Controllers/HudController
local HudController = {}
function HudController.Start()
print("client booted")
end
return HudController

The client needs its own Start call, pointed at its own folder. One LocalScript does it.

StarterPlayerScripts/Client
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Twill = require("@game/ReplicatedStorage/Twill")
Twill.Lifecycle.Start(ReplicatedStorage:WaitForChild("Client"):WaitForChild("Controllers"))

Controllers live where a client can reach them, which means ReplicatedStorage or StarterPlayerScripts. Nothing a controller can read is private, so treat everything in it as published.

What the client gets, and what it does not

Section titled “What the client gets, and what it does not”

Lifecycle behaves the same on both sides with one exception.

Service (server)Controller (client)
Discovery, Priority, CriticalYesYes
Init then StartYesYes
OnPlayerReady / OnPlayerRemovingYesNo

The player pipeline is server-only on purpose. On a client, PlayerAdded means somebody else joined, so a per-player hook there would mean something entirely different from what it means on the server. Each side also keeps its own boot list, so a folder given to one is invisible to the other.

For the local player, use Players.LocalPlayer in Start, and wait on state rather than on the player.

A controller never asks the server for anything. There is no such call. Values arrive because the server decided to publish them, and Replication delivers them.

ReplicatedStorage/Client/Controllers/HudController
local Twill = require("@game/ReplicatedStorage/Twill")
local HudController = {}
function HudController.Start()
local coinLabel = getCoinLabel()
-- Runs once immediately if the value is already known, then on every
-- change. There is no separate "read the current value" step.
Twill.Replication.Subscribe("Data.Coins", function(coins)
coinLabel.Text = Twill.Format.Comma(coins or 0)
end)
end
return HudController

Nothing in the shop code publishes coins. A field named once in Data.Configure is doing it, following the server’s data.Coins -= price on its own.

The client announces itself the first time its half of the module is required. If no client module ever requires it, the server has nothing to send to and the controller waits forever for a value that was never addressed to it.

Where a controller cannot draw anything until a value exists, wait for it. This yields, so it belongs in Start, never at the top level of a module.

function HudController.Start()
local loadout, arrived = Twill.Replication.WaitFor("Data.Loadout", 15)
if not arrived then
Twill.Log.new("Hud"):Warn("loadout never arrived; drawing the empty state")
return
end
draw(loadout)
end

A controller has no player bag handed to it, because on the client there is only ever one player and their lifetime is the session’s.

Use Scope directly. For anything tied to the character, the choice of bag is the whole decision:

local RunService = game:GetService("RunService")
local Twill = require("@game/ReplicatedStorage/Twill")
local function onCharacterAdded(character: Model)
-- Stops the instant they die.
Twill.Scope.Alive(character):Connect(RunService.RenderStepped, updateCrosshair)
-- Survives the death, so a ragdoll effect can still finish.
Twill.Scope.Character(character):Add(spawnDeathEffect)
end

Long-lived work that belongs to the session goes in Scope.Framework().

Declare the remote in a module both sides require, then call it from a controller.

ReplicatedStorage/Shared/Remotes/Shop
local Net = require("@game/ReplicatedStorage/Twill/Net")
local Types = Net.Types
return {
BuyItem = Net.Declare("BuyItem", { Types.String(32) }, { Types.Boolean, Types.String(64) }),
}
ReplicatedStorage/Client/Controllers/ShopController
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Remotes = require(ReplicatedStorage.Shared.Remotes.Shop)
local ShopController = {}
function ShopController.Start()
buyButton.Activated:Connect(function()
-- The remote replies, so this waits for the server's answer.
local bought, message = Remotes.BuyItem:Ask("sword")
if not bought then
showToast(message)
end
end)
end
return ShopController

A remote declared on the client is given its number by the server, and that arrives a moment later. A call made before then is held and sent once it does, so a controller does not have to be careful about when it fires. Use Net.OnReady when you want to know the moment it happens.

The client renders and requests. It does not rule.

Reading a rank on the client is fine, and it is the right way to avoid showing a button that would only be refused:

if Twill.Authorization.AtLeast(player, Ranks.Moderator) then
moderatorPanel.Visible = true
end

That decides what is shown. What is allowed is decided again on the server, by MinimumRank on the handler, because a hidden button is not a closed door. Anyone can call the remote directly.