User Interface
Luduvo keeps user interface state in the same Entity Component System (ECS) tables that hold the 3D world. A screen element is an entity, and everything it shows is a component on that entity, so the same scripting API that moves a part in the world moves a button on the screen.
How the client stores and walks that state inside: UI Layout and Text.
The ECS UI model#
A screen element is an entity ID indexing UI component rows:
UIRect carries the spatial transform metrics (Position, Size, MaxSize,
AnchorPoint, SizeMode), UIFrame the background plate (BackgroundColor3,
BackgroundTransparency), UIStroke the border line outline (BorderColor3,
BorderSizePixel), and UICorner the corner rounding radius. UIImage holds a
texture asset ID, colour tint, and transparency; UITextSource an interned
32-bit TextId string reference; and UITextStyle a 44-byte font formatting
struct (fontSize, color, stroke, alignments).
These rows compose additively: an entity with a UIFrame row draws a background plate;
an entity with a UIImage row draws a texture; an entity with both draws both visual layers; and an
entity with neither acts as a spatial container that still participates in layout
calculations and hit-testing without rendering geometry.
UI state replicates across the network like any other component row, the same
way Position or Health does.
Layout on the client#
Everything that turns UI component rows into rendered pixels runs exclusively on the client. The server does not evaluate layout or dispatch draw calls; it replicates the component rows over the network.
Each frame, the client turns those rows into pixels in four passes:
The rebuild pass walks the ECS Parent chains from each root overlay entity
(UIRoot) and collects every visible UI entity in tree order, so a parent is
always resolved before its children.
Layout resolves each element’s position and size against its parent and produces the rectangle the element occupies. The scissor and hit-test passes carry clipping boundaries down the tree and decide which element sits under the pointer. Batch draw generation turns the resolved rectangles into the draw batches the GPU consumes.
The overlay tree has two hard limits:
- Maximum hierarchy depth: 64 levels.
- Maximum capacity: 8,192 total nodes across all overlays (68 nodes are reserved for engine core menus, leaving 8,124 nodes for place UI).
If a subtree exceeds either threshold, the client safely drops it and logs ui sync cut N subtrees.
Canvas and hierarchy#
Top-level overlay entities have the UIRoot tag, which serves as the engine’s equivalent of a ScreenGui. During each rebuild pass, the engine walks every UIRoot once, gathering all
descendants through the ordinary Parent chain into a single screen-space overlay that covers the
entire window. Nesting a UIRoot under another UIRoot cuts the descendant
root rather than merging it into the overlay.
Each descendant requires a UIRect component to participate in layout
resolution. If a script writes a Gui property on an entity lacking UIRect,
the engine attaches a default UIRect component. Setting Gui properties on a
3D world entity raises an error: a world entity has no Gui properties.
Shape the UI tree without rendering any geometry using two structural tags:
| Tag | Script property | Effect |
|---|---|---|
UIHidden | Visible (inverted) | Skips layout, drawing and hit-testing. |
UIClips | ClipsDescendants | Scissors descendants to rect. |
Setting Visible = false adds UIHidden; setting Visible = true removes it. The
tag presence represents the state directly: a saved place file stores one byte per
hidden element and nothing for visible ones.
The UIRect coordinate model and UDim2 math#
Adaptive UI layouts must respond fluidly to changing screen resolutions while keeping borders, margins, and alignments pixel-crisp. The engine achieves this using UDim2 (2D Universal Dimension) coordinates.
1D coordinate resolution#
A UDim2 holds two UDim values - one for the X axis and one for the
Y axis. Each UDim contains two numbers:
scale(floating-point, typically0.0to1.0): A normalized fraction of the parent container’s resolved dimension (0.5represents 50% of the parent’s width or height).offset(floating-point or integer pixels): An absolute pixel offset added to the scaled dimension.
The fundamental formula to resolve a UDim into screen pixels along any axis is:
resolved_pixels = parent_size * scale + offsetIn Lua, construct a 2D dimension using UDim2.new(scaleX, offsetX, scaleY, offsetY).
The layout engine resolves an element’s size directly against the parent’s resolved width and height:
element_width = parent_width * size.x.scale + size.x.offsetelement_height = parent_height * size.y.scale + size.y.offsetUIRect exposes the following layout properties:
| Property | Type | Meaning |
|---|---|---|
Position | UDim2 | Where anchor point lands in parent. |
Size | UDim2 | Width and height without size mode. |
MaxSize | UDim2 | Upper bound limit per axis. |
AnchorPoint | Vector2 | Normalized pivot from 0 to 1. |
SizeModeX, SizeModeY | number | 0 Fixed, 1 Fit, 2 Grow. |
Positioning and AnchorPoint math#
The AnchorPoint is a normalized 2D vector (anchorX, anchorY) between 0.0 and 1.0
that defines a local pivot point inside the element itself:
(0.0, 0.0)is the top-left corner.(0.5, 0.5)is the exact center.(1.0, 1.0)is the bottom-right corner.
An element’s Position specifies where its AnchorPoint lands inside the parent container.
To determine the element’s actual top-left screen position, the layout engine first resolves the
element’s dimensions, calculates the anchored reference point inside the parent, and then subtracts
the local anchor offset:
element_pos = parent_pos + (parent_size * pos.scale + pos.offset) - (element_size * anchor)Expanding this per axis yields the exact pixel boundaries:
left = parent_left + (parent_width * pos.x.scale + pos.x.offset) - (element_width * anchor.x)top = parent_top + (parent_height * pos.y.scale + pos.y.offset) - (element_height * anchor.y)right = left + element_widthbottom = top + element_heightWorked example: Centered modal dialog
Consider a parent screen measuring 800 x 600 pixels positioned at (left = 0, top = 0).
Configure a dialog box with:
Size = UDim2.new(0, 400, 0, 300)Position = UDim2.new(0.5, 0, 0.5, 0)AnchorPoint = Vector2.new(0.5, 0.5)
Resolve size:
Textelement_width = 800 * 0.0 + 400 = 400 pxelement_height = 600 * 0.0 + 300 = 300 pxResolve position:
Textleft = 0 + (800 * 0.5 + 0) - (400 * 0.5) = 400 - 200 = 200 pxtop = 0 + (600 * 0.5 + 0) - (300 * 0.5) = 300 - 150 = 150 px
The layout engine places the dialog at (200, 150) with dimensions 400 x 300. When
the window resizes to 1920 x 1080, the layout pass re-evaluates:
left = 0 + (1920 * 0.5) - (400 * 0.5) = 960 - 200 = 760 pxtop = 0 + (1080 * 0.5) - (300 * 0.5) = 540 - 150 = 390 pxThe dialog centers without script logic.
Size modes#
Configure size modes to override the Size value on an axis:
- Fixed: Uses
Sizeexactly as written. - Fit: Sizes the axis to match its content (either the measured text of a label
or the bounding extent of all child elements for a container).
Sizeon that axis is ignored. - Grow: Operates specifically under a
UIListLayout. Along the list axis, grow children split whatever space the fixed and fit siblings leave over (in 1/64 pixel units, distributing any remainder one unit at a time starting from the first grow child). Across the cross-axis, a grow child expands to fill the parent container. Outside a list layout, Grow behaves identically to Fixed.
MaxSize clamps the resolved dimension after the size mode produces a result.
For example, a Fit label with MaxSize = UDim2.new(0, 300, 0, 0) wraps at 300 pixels,
while a Grow panel never outgrows its specified ceiling. A MaxSize scale
referencing the parent is ignored while the parent itself is measuring its content,
preventing circular dependencies.
local panel = game.Prefabs.Spawn("Part")panel.Parent = screenpanel.Position = UDim2.new(0.5, 0, 1, -24)panel.AnchorPoint = Vector2.new(0.5, 1)panel.Size = UDim2.new(0, 480, 0, 0)panel.SizeModeY = 1panel.MaxSize = UDim2.new(0.9, 0, 0.5, 0)Visual components#
Each visual component row renders inside the rectangle resolved by the layout pass.
These rows are completely modular: a typical interactive button consists of UIFrame + UIStroke + UICorner + a text row, and removing any single component
strips away only that visual layer.
| Row | Studio fields | Script properties |
|---|---|---|
UIFrame | Fill, Opacity | BackgroundColor3, BackgroundTransparency |
UIStroke | Color, Opacity, Width | BorderColor3, BorderSizePixel |
UICorner | Radius | CornerRadius |
UIImage | Asset, Tint, Alpha | Image, ImageColor3, ImageTransparency |
UIFrame represents the background plate: a filled rectangle drawn behind everything else
on the element. By default, it fills with a neutral grey at full opacity. UIStroke draws an outline border of Width pixels around the plate, while UICorner rounds both
the plate and the stroke using a single radius. Without a plate or stroke attached, a
corner component does nothing visible other than rounding a UIClips scissor boundary.
UIImage renders the texture asset identified by its asset ID, multiplied by the color tint.
In scripts, pass the asset ID as a decimal string, because a long ID loses precision as a Lua
number; passing "0" draws nothing, and a texture not yet downloaded renders blank until the
asset arrives. The engine stores the tint as four bytes, so ImageColor3 quantizes to 1/255 on write.
Studio exposes opacity (where 0 is hidden and 1 is solid). In scripts, follow the standard
convention where BackgroundTransparency, BorderTransparency, and ImageTransparency equal 1 - opacity, meaning writing 0 makes the layer fully opaque.
Text rendering and MSDF fonts#
Text rendering is split across two component rows:
UITextSource: the string the element displays, stored as an interned 32-bitTextId. Studio and scripts setTextas a string and the engine interns it automatically;TextIdremains read-only in the property panel.UITextStyle: a 44-byte struct holding font appearance - size, color, stroke width and stroke color, horizontal and vertical alignment, bold.
Multi-Channel Signed Distance Fields (MSDF)#
Glyphs are drawn from a Multi-Channel Signed Distance Field (MSDF) atlas instead of pre-rendered bitmaps. A bitmap font blurs when scaled up, pixelates when scaled down, and needs a separate atlas for every font size. A single-channel distance field keeps curves smooth under magnification but rounds off sharp corners, such as the apex of an ‘A’ or the corners of an ‘M’. MSDF keeps curves and corners sharp, so one atlas serves every size from 12px up to 512px without blur or pixelation.
Glyphs and outlines
The outline follows the same glyph shape, so a text stroke needs no second
pass and no extra geometry. The body of the glyph takes the fill color
(TextColor3), a band of TextStrokeWidth pixels around it takes the outline
color (TextStrokeColor3), and everything outside that band is transparent.
| Studio field | Script property | Meaning |
|---|---|---|
| Font Size | TextSize | Pixels, 1 to 512. |
| Color, Opacity | TextColor3, TextTransparency | Fill of the glyphs. |
| Outline Color/Opacity | TextStrokeColor3, TextStrokeTransparency | Outline color and alpha. |
| Outline Width | TextStrokeWidth | Pixels; zero disables. |
| Bold | Bold | Bold font weight. |
| H Align | TextXAlignment | 0 Left, 1 Center, 2 Right. |
| V Align | TextYAlignment | 0 Top, 1 Center, 2 Bottom. |
| No Translate | NoTranslate | Disables translation. |
Unless No Translate is enabled, any text matching a key in the place’s translation table
automatically displays in the player’s locale. Set this flag on player usernames, numbers,
and strings built dynamically at runtime.
Setting Fit size mode on a text entity causes the engine to measure the glyph run so the label
automatically grows with its string; combining a Fixed width with MaxSize produces a cleanly
wrapping label.
Text input#
Attaching a UITextInput component turns a text entity into an interactive, editable field.
This row holds only what the author sets: the placeholder text, a maximum
character length, cursor color, and the password masking flag. Caret position,
active selection, and input focus stay on the client and never replicate to
the server.
| Studio field | Script property | Meaning |
|---|---|---|
| PlaceholderId | PlaceholderText | Text when empty. |
| Cursor Color/Opacity | CursorColor3, CursorTransparency | Caret color and alpha. |
| Password | Password | Masks typed characters. |
The typed string updates the same Text property, so a client script can read field.Text directly to obtain the user’s input. A server script never sees individual keystrokes; when
input completes, send the finished string through an event table.
Layout containers and ordering#
Attaching a layout row to a parent element takes over the positioning of every visible child.
While a parent holds a layout component, children’s Position and AnchorPoint properties
have no effect; each child preserves its own Size under a list layout, but has its size
overridden under a grid layout. An entity can take either a list or a grid layout, never both.
UIListLayout#
UIListLayout stacks children along one axis with even spacing:
| Field | Values | Effect |
|---|---|---|
| Axis | 0 Vertical, 1 Horizontal | Stacking direction. |
| Align | 0 Start, 1 Center, 2 End | Cross-axis alignment. |
| Spacing | pixels | Gap between children. |
| Margins | pixels (L, T, R, B) | Inset from parent edges. |
In scripts, access these fields as Axis, Align, Spacing, MarginLeft, MarginTop, MarginRight, and MarginBottom. The list layout also computes the canvas height of a UIScrollingFrame on the same entity: setting canvas size to zero sets the
canvas height to the total item extent.
UIGridLayout#
UIGridLayout steps children through uniform cells, left to right and
then down:
| Field | Script property | Effect |
|---|---|---|
| Columns | Columns | Cells per line. |
| Cell Width/Height | CellSize (Vector2) | Fixed size of one cell. |
| Spacing | CellPadding | Pixels between cells. |
| Margins | Margins (Rect) | Inset from edges. |
A scrolling frame placed over a grid derives its canvas height directly from the total row count.
UIOrder#
Control rendering and layout sequence using UIOrder, which packs two 16-bit integers:
ZIndex: Dictates the draw order among siblings. Higher values draw last, appearing on top.ZIndexnever reorders an element relative to its parent’s siblings; a child is always drawn after its parent.LayoutOrder: Dictates the element’s position within aUIListLayoutorUIGridLayout. Lower values appear first. Siblings with equalLayoutOrderpreserve the order in which they were added.
local hotbar = game.Prefabs.Spawn("Part")hotbar.Parent = screenhotbar.Position = UDim2.new(0.5, 0, 1, -16)hotbar.AnchorPoint = Vector2.new(0.5, 1)hotbar.Size = UDim2.new(0, 0, 0, 56)hotbar.SizeModeX = 1hotbar.Axis = 1hotbar.Align = 1hotbar.Spacing = 6for slot = 1, 9 do local cell = game.Prefabs.Spawn("Part") cell.Parent = hotbar cell.Size = UDim2.new(0, 48, 0, 48) cell.LayoutOrder = slot cell.CornerRadius = 6 cell.BackgroundTransparency = 0.4endInteractive controls and events#
By default, nothing on screen intercepts mouse or touch input. During hit-testing, the engine tests the topmost visible rectangle under the cursor and walks upward until it finds an element that explicitly opted in. Decorative frames nested beneath buttons do not intercept pointer events unless configured.
| Row | Script property | Effect |
|---|---|---|
UIClickable | UIClickable | Opts element into hit-testing. |
UIDisabled | Disabled | Refuses pointer/keys, dims. |
UIScrollingFrame | CanvasSize (UDim2) | Wheel/drag scroll canvas. |
UIToggle + UIToggleOn | On, style colors | Pill track and toggle knob. |
UISlider / state | Steps, Value, style | Stepped slider track. |
Toggles and sliders capture the pointer on their own; they do not require an extra UIClickable component. Their value always corresponds to a step in a discrete list rather than an arbitrary
number: for instance, a volume slider with 11 steps reports 0 to 10, which scripts map
to application logic. When either control holds keyboard focus, Left or Down steps backward, and
Right or Up steps forward.
The engine strictly respects UIDisabled across all interaction paths: hit-testing returns nothing
for a disabled node, an active drag gesture releases immediately if the element becomes disabled
mid-motion, and keyboard arrow navigation clears focus from it.
Signals#
Every pointer signal fires directly on the entity, and exclusively within client scripts. Pointer
events never reach the server; connecting one of these signals from a server script logs Activated never fires in a server script and the handler is never invoked.
| Signal | Arguments | Fires when |
|---|---|---|
Activated | character | Click/tap, toggle flip, slider step. |
Hovered | entered: boolean | Pointer enters (true) or leaves. |
DragMoved | dx, dy: number | Pointer moves while dragging. |
character references the local player’s predicted character (or nil before one is spawned). dx and dy represent this frame’s delta in layout pixels; sum them over time to drag an element
across the screen.
Because these signals remain inside the client process, responding to clicks that affect the game
world requires pushing a row into an EventTable, which the server reads on the next simulation tick:
-- shared declaration, same in both scriptslocal ET = game.EventTablelocal UseSlot = ET("UseSlot", ET.ToServer, {{"slot", ET.U8}})-- Hotbar.client.luafor slot = 1, 9 do local cell = hotbar:FindFirstChild("Slot" .. slot) cell.UIClickable = true cell.Hovered:Connect(function(entered) cell.BackgroundTransparency = entered and 0.1 or 0.4 end) cell.Activated:Connect(function() UseSlot:Push(slot) end)end-- Inventory.server.luafunction Update(dt) for i = 1, UseSlot.count do useSlot(UseSlot.sender[i], UseSlot.slot[i]) endendOnly the server can see sender, preventing a malicious client from claiming another player’s click.
Rows live for a single simulation tick; for full limits and supported field types, see Server Authority and Replication.
Client scripts can also modify UI component rows directly: toggling Visible, updating background
tints on Hovered, or assigning Text from local state all update the screen on the very next
frame with zero network latency. These local writes stay on the client. For state that all players
must see synchronously (such as a shared scoreboard), write the components on the server and let
them replicate down to all clients automatically.