Skip to content

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:

ServerScriptService/Services/WelcomeService
local WelcomeService = {}
function WelcomeService.OnPlayerReady(player, data)
print(`{player.Name} arrived`)
end
return WelcomeService

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
└── Main

Every 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,
})
local ShopService = {}
ShopService.Priority = 10 -- lower boots first; ties break by name
ShopService.Critical = true -- a failed boot here refuses every player
function ShopService.Init() end
function ShopService.Start() end
function ShopService.OnPlayerReady(player, data, bag) end
function ShopService.OnPlayerRemoving(player) end
return ShopService

Hooks are called dot-style, so none of them receives self. Write function ShopService.Start(), not function ShopService:Start().

This is the one distinction worth learning properly, because getting it wrong produces a bug that only appears when boot order changes.

InitStart
RunsSequentially, for every serviceAfter every Init has finished
Put hereYour own state, your own configurationConnections, loops, calls to other services
Reaching another serviceNot safe. It may not be set up yetSafe
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)
end

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)
end

Both 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:

  • require a sibling service directly instead of asking Lifecycle. That is a real Luau cycle and it will fail exactly as it would anywhere else.
  • Wait inside Start for something another Start sets. 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.

Start is what begins letting players through, so anything the framework itself needs must already be set.

ServerScriptService/Main
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)

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)
end

The 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.

Putting it together. The handler is a plain local function, and Start is where it gets served.

ServerScriptService/Services/ShopService
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 ShopService

Mark a service Critical when the game is not worth playing without it.

ShopService.Critical = true

A 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.