Write a service
A service is a server-side ModuleScript that returns a table. That is the
whole contract. Lifecycle finds it, boots it in an
order you control, and hands it each player once their data exists.
Its client-side counterpart is a controller. The
module shape is identical and the same Lifecycle boots both; the two words
exist so it is always clear which side a module runs on.
Every field is optional, so this is already a complete service:
local WelcomeService = {}
function WelcomeService.OnPlayerReady(player, data) print(`{player.Name} arrived`)end
return WelcomeServiceWhere services live
Section titled “Where services live”Lifecycle.Start takes the folder to search. Only direct children are taken,
and each must be a ModuleScript returning a table.
ServerScriptService├── Twill├── Services│ ├── ShopService│ └── CombatService└── MainEvery ModuleScript in the folder that returns a table is booted as a service,
including a plain data module such as a catalog. Keep data modules outside the
folders you pass to Start.
Nesting a folder inside Services does not extend the search. Twill warns
rather than boot what it never found, so pass the inner folder to Start as
well:
Twill.Lifecycle.Start({ ServerScriptService.Services, ServerScriptService.Systems,})The full shape
Section titled “The full shape”local ShopService = {}
ShopService.Priority = 10 -- lower boots first; ties break by nameShopService.Critical = true -- a failed boot here refuses every player
function ShopService.Init() endfunction ShopService.Start() endfunction ShopService.OnPlayerReady(player, data, bag) endfunction ShopService.OnPlayerRemoving(player) end
return ShopServiceHooks are called dot-style, so none of them receives self. Write
function ShopService.Start(), not function ShopService:Start().
Init or Start
Section titled “Init or Start”This is the one distinction worth learning properly, because getting it wrong produces a bug that only appears when boot order changes.
Init | Start | |
|---|---|---|
| Runs | Sequentially, for every service | After every Init has finished |
| Put here | Your own state, your own configuration | Connections, loops, calls to other services |
| Reaching another service | Not safe. It may not be set up yet | Safe |
function ShopService.Init() -- Only this service's own state. ShopService.stock = {}end
function ShopService.Start() -- Everything that reaches outward waits until here. Twill.Net.Handle(Remotes.BuyItem, onBuy, { Rate = 2, Reject = refuse })
local combat = Twill.Lifecycle.Get("CombatService") combat.RegisterShop(ShopService)endTwo services that need each other
Section titled “Two services that need each other”InventoryService needs EconomyService, and EconomyService needs
InventoryService. In most modular frameworks that is a problem to solve. Here
it is not one, and the reason is worth knowing.
Services never require each other. Lifecycle.Get reads a registry filled
when the folder was collected, before any Init ran. Nothing about reaching
another service is a Luau require, so there is no require cycle to form:
function InventoryService.Start() Twill.Lifecycle.Get("EconomyService").RegisterSink(InventoryService)end
function EconomyService.Start() Twill.Lifecycle.Get("InventoryService").RegisterSource(EconomyService)endBoth work. By the time any Start runs, every Init has finished, so neither
service is reaching for something half built.
Boot cannot deadlock either. Init runs straight through without waiting on
anybody, and each Start runs on its own thread, so no phase is ever blocked on
another service reaching a phase.
There are two ways to build a cycle anyway, and both mean leaving the pattern:
requirea sibling service directly instead of askingLifecycle. That is a real Luau cycle and it will fail exactly as it would anywhere else.- Wait inside
Startfor something anotherStartsets. Two services each waiting on the other wait forever. Ask for what you need when you need it rather than blocking until it appears.
Where two services should not know about each other at all,
Replication.OnChanged lets one react to another’s
published state without either naming the other.
Configuration comes before Start
Section titled “Configuration comes before Start”Start is what begins letting players through, so anything the framework itself
needs must already be set.
local Twill = require("@game/ServerScriptService/Twill")
Twill.Data.Configure({ Store = "PlayerData", Template = { Coins = 100 } })Twill.Authorization.Configure({ Default = 10, Users = { [1] = 100 } })Twill.Lifecycle.SetPlayerGate(Twill.Data.Gate)
Twill.Lifecycle.Start(script.Parent.Services)Receiving a player
Section titled “Receiving a player”OnPlayerReady runs once per player, after the gate has released them. It
receives three things:
function ShopService.OnPlayerReady(player, data, bag) -- 1. the player -- 2. whatever the gate released, which with Data.Gate is their live profile data.Visits += 1
-- 3. a bag that closes when they leave, so this is never left behind bag:Connect(player.Chatted, onChatted)endThe third argument is why most services never touch Scope
directly. Anything you put in that bag is released when the player leaves,
whether they left cleanly or the server is shutting down.
OnPlayerRemoving runs in reverse boot order, so a service still sees the ones
it was allowed to depend on. The bag closes only after every service has had its
say, which means you can still read your own state there.
OnPlayerRemoving fires only for players who reached ready. Someone who
disconnected while still waiting at the gate was never announced, so no service
has state to unwind for them.
A service that guards a remote
Section titled “A service that guards a remote”Putting it together. The handler is a plain local function, and Start is where
it gets served.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Twill = require("@game/ServerScriptService/Twill")local Remotes = require(ReplicatedStorage.Shared.Remotes.Shop)
local ShopService = {}
local CATALOG = { sword = 50, shield = 75, potion = 10,}
-- Receives the firing player first. By the time this runs, the call has already-- cleared the rate limit and the schema, so what is left is the game's own rules.local function onBuy(player: Player, itemId: string): (boolean, string) local price = CATALOG[itemId] if not price then return false, "no such item" end
-- Nil means their session is not open, which is every moment before they -- are released and after they leave. local data = Twill.Data.Get(player) if not data then return false, "not ready" end
if data.Coins < price then return false, "not enough coins" end
-- Mutating the live profile is the whole write. There is no commit step. data.Coins -= price table.insert(data.Inventory, itemId)
return true, `bought {itemId}`end
function ShopService.Start() Twill.Net.Handle(Remotes.BuyItem, onBuy, { -- Two calls a second per player. Metering applies whether or not this -- line is here, so it is a choice of number, not a choice to have one. Rate = 2,
-- The declaration already guarantees the type. This bounds the length, -- which a wire type cannot express. Schema = { { "string", 1, 20 } },
-- Required, because the remote replies. Without it a refused caller -- would wait, so Twill refuses to serve the remote at all. Reject = function() return false, "slow down" end, })end
return ShopServiceFailing loudly
Section titled “Failing loudly”Mark a service Critical when the game is not worth playing without it.
ShopService.Critical = trueA critical service that throws in Init or Start locks the server: everyone
present is kicked, everyone arriving afterwards is kicked on sight, and the
reason reaches both the kick message and Lifecycle.GetFailure().
A service that is not critical is logged and the boot carries on. Choose deliberately. A shop that fails to boot is an annoyance; a data layer that fails to boot is a save file about to be overwritten with a fresh template.
- Write a controller is the same module shape on the client.
- Define and serve remotes covers validation, rank gating, and auditing what a client can send.
- Store and save player data covers migrations, branches, and writing to players who are not here.