Skip to content

Build interfaces from data

Building UI by hand produces the same forty lines every time: create, set six properties, parent, repeat, then hunt for the label again later with a chain of FindFirstChild.

Tree takes the description instead. You write what the screen is, get back the pieces you named, and bind them to state yourself.

This is deliberately not a view layer. Twill has no reconciliation and no state binding of its own; what it has is a builder and a subscription, which together cover most of a HUD without bringing in a framework.

local root, refs = Twill.Tree.Build({
ClassName = "Frame",
Name = "CoinCounter",
Size = UDim2.fromOffset(200, 44),
BackgroundTransparency = 0.3,
Children = {
{
ClassName = "UICorner",
CornerRadius = UDim.new(0, 8),
},
{
ClassName = "TextLabel",
-- Named, so it can be reached later without searching for it.
Ref = "Amount",
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Text = "0",
},
},
}, playerGui)
refs.Amount.Text = "ready"

Any key that is not one of the six reserved names is written as a property, so a spec reads like the instance it becomes.

A Ref plus a subscription is the whole binding.

ReplicatedStorage/Client/Controllers/HudController
local Twill = require("@game/ReplicatedStorage/Twill")
local HudController = {}
function HudController.Start()
local root, refs = Twill.Tree.Build(HUD_SPEC, playerGui)
-- Fires once immediately if the value is already known, so there is no
-- separate "draw the initial state" step.
Twill.Replication.Subscribe("Data.Coins", function(coins)
refs.Amount.Text = Twill.Format.Comma(coins or 0)
end)
end
return HudController

Nothing publishes coins here. A field named in Data.Configure does it, and the label follows the server’s own write.

One subscription per field reads fine for two fields and repeats badly for ten. The repetition is always the same shape, a path, a ref, a property, and a formatter, so write it down once, in your own controller:

local function bind(path: string, instance: Instance, property: string, show: ((any) -> any)?)
Twill.Replication.Subscribe(path, function(value)
(instance :: any)[property] = if show then show(value) else value
end, bag)
end
bind("Data.Coins", refs.Coins, "Text", Twill.Format.Comma)
bind("Data.Level", refs.Level, "Text", tostring)
bind("Data.Boost", refs.BoostIcon, "Visible")

Six lines buy you one line per binding, and the shape stays yours: a panel that wants a colour rule or a tween instead of a plain write changes bind rather than arguing with a framework about it.

Twill does not ship that helper on purpose. It is six lines, every game wants it slightly differently, and a Bind in the framework would have to guess about transforms, defaults, and what a nil means on a property that cannot hold one.

Events connects signals by name, after the properties are written.

{
ClassName = "TextButton",
Text = "Buy",
Events = {
Activated = function()
local bought, message = Remotes.BuyItem:Ask("sword")
if not bought then
showToast(message)
end
end,
},
}

Properties and attributes are set before anything is connected, so a GetPropertyChangedSignal handler passed through Events hears only the changes you make afterwards, not the ones the build itself performed.

Children are built after the events connect, so a handler that could fire immediately should not assume the rest of the tree exists yet.

A spec is data, so it can be a function of its input and built as many times as you need.

local function slotSpec(index: number)
return {
ClassName = "Frame",
Name = `Slot{index}`,
LayoutOrder = index,
Size = UDim2.fromOffset(64, 64),
Children = {
{ ClassName = "TextLabel", Ref = `Label{index}`, Text = "" },
},
}
end
for index = 1, 9 do
Twill.Tree.Build(slotSpec(index), inventoryFrame)
end

Build collects every Ref at any depth into one flat table. Two instances asking for the same name means the later one wins and the earlier one becomes unreachable. Where a spec is built repeatedly, put the index in the name, as above.

Events connections last until the tree is destroyed. For UI under a PlayerGui that is enough, because Roblox removes it with the player.

Anything longer-lived, or anything parented into the world, should have an owner. Pass a Scope bag as the third argument, and the tree is destroyed, connections and all, when the bag closes:

local root = Twill.Tree.Build(spec, workspace, bag)

Subscriptions are separate and need the same treatment:

bag:Add(Twill.Replication.Subscribe("Data.Coins", onCoins))

Tree builds once. It does not re-render when your state moves, and it keeps no view state.

That is fine for a HUD, where a handful of labels follow a handful of values. It stops being fine when the shape of the screen depends on state: a list whose length changes, a panel that swaps layouts, anything you would otherwise rebuild by destroying and recreating.

At that point reach for a reactive library. Twill has no opinion and no integration to get in the way, and you can keep using it for everything that is not the view.