Screens
UIController binds each screen module in Game/Screens to the Studio-authored ScreenGui of the same name and manages opening, closing, modal exclusivity and gamepad selection.
UI is built in Studio and driven from code. Each ScreenGui is authored in StarterGui and saved in the place file; each screen module in src/Client/Game/Screens drives the ScreenGui with the same name. UIController, a controller that ships in the skeleton, finds the modules, binds each one to its ScreenGui whenever a copy reaches the player’s PlayerGui, and owns opening, closing, modal exclusivity and gamepad selection. A screen presents replicated values and sends requests through channels. It never decides an outcome. Code that builds UI trees with Instance.new is the pattern this replaces.
Binding
Section titled “Binding”UIController.start does the following:
- Collects every
ModuleScriptdirectly insideGame/Screens, keyed by name. Without aScreensfolder, UIController does nothing. - For each module, checks
StarterGui.<Name>. When the original hasResetOnSpawnon, it warns:[UIController] StarterGui.<Name> has ResetOnSpawn on: set it off in Studio or the screen is rebuilt on every respawn. - Watches
PlayerGui.ChildAddedand binds every child already present. IfPlayerGuidoes not exist yet, it waits for it onLocalPlayer.ChildAdded. - Arms one 15-second reminder on the kernel Scheduler. When it fires, every screen module that never found its gui warns
still waiting for PlayerGui.<Name>: author a ScreenGui named <Name> in StarterGui.
StarterGui copies its guis into PlayerGui when the character first spawns, and again on every respawn while the original has ResetOnSpawn on. That is why binding follows ChildAdded instead of looking once at start.
Binding one gui:
- A child whose name matches no module is ignored. A matching child that is not a
ScreenGuiwarns and is skipped. - If a screen with that name is already bound (the previous copy), it is unbound first: its
destroyruns. - The theme is linked (see Theme).
- The module is required and
Module.new(context, gui)builds the screen object. - The gui’s
Destroyingis connected, so destroying the copy unbinds the screen and keeps its open state for the next copy. - The screen opens if it was open or had a pending
openbefore, or if this is its first bind andStartsOpenis true. Otherwise itscloseruns, so every screen starts in a known state whateverEnabledwas in Studio.
Opening and closing
Section titled “Opening and closing”Other code reaches the controller through getService, and screens through their context:
local UI = self.Kernel:getService("UIController")UI:open("Vote") -- kept until the Vote gui reaches PlayerGuiif UI:isOpen("Shop") then UI:close("Shop")endopen before the gui exists is remembered. The screen opens the moment its gui binds, so a controller can open the HUD or a shop in its own start without waiting for the character to spawn. open with a name that has no module warns open("<name>"): no screen module named <name> in Game.Screens.
A screen with Modal = true is exclusive with other modals. Opening a modal closes every other open modal first. Non-modal screens (the HUD, toasts) stay open. When the player’s UserInputService.PreferredInput is Gamepad or MicroGamepad, opening a modal calls GuiService:Select on its gui, which moves gamepad selection inside it. When the player switches to a gamepad while a modal is open, selection moves into that modal unless it is already inside. Closing a screen clears GuiService.SelectedObject when the selection was inside that screen.
UIController API
Section titled “UIController API”| Member | Description |
|---|---|
UIController:open(name) |
Opens the screen, closing other modals if it is modal. Kept as pending until the gui binds. Warns for an unknown name. |
UIController:close(name) |
Closes the screen and drops a pending open. Clears a gamepad selection inside it. |
UIController:isOpen(name) -> boolean |
True while the screen is bound and open. A pending open reads false. |
The screen module
Section titled “The screen module”A screen module returns a table with new(context, gui), which returns the screen object:
| Member | Description |
|---|---|
Screen.Modal |
true for a screen that is exclusive with other modals and takes gamepad selection. |
Screen.StartsOpen |
true to open on the first bind of the session. Later copies keep whatever state the previous copy had. |
Screen:open() |
Shows the screen. Usually gui.Enabled = true plus any refresh. |
Screen:close() |
Hides the screen. |
Screen:destroy() |
Releases everything new created. Runs when the gui copy is destroyed or replaced, and on UIController.stop. |
python tools/scaffold.py screen Vote writes the minimal module with the authoring rules in its header:
--!strict-- Vote screen: drives the Studio-authored ScreenGui named Vote.-- Author StarterGui.Vote in Studio with:-- ResetOnSpawn off (on, every respawn clones a fresh unscripted copy).-- ScreenInsets CoreUISafeInsets when it holds buttons or key information;-- None only for a non-interactive background or fade in its own ScreenGui.-- SafeAreaCompatibility None and ZIndexBehavior Sibling.-- DisplayOrder in its band: HUD 0-9, menus 10-19, modals 20-29, toasts 30+.-- Text sized by AutomaticSize rather than TextScaled, never below 9 px.-- Modal = true below: in a mouse-lock game (first person, shift lock) the-- gui also holds one visible GuiButton with Modal = true (the close button-- works), or the cursor stays locked and nothing can be clicked.
export type Screen = { Modal: boolean, StartsOpen: boolean, open: (self: Screen) -> (), close: (self: Screen) -> (), destroy: (self: Screen) -> (),}
local Vote = {}
function Vote.new(context: any, gui: ScreenGui): Screen local Scope = context.Kernel:createScope() local Screen = { Modal = false, StartsOpen = false, } :: any function Screen.open(_self: Screen) gui.Enabled = true end function Screen.close(_self: Screen) gui.Enabled = false end function Screen.destroy(_self: Screen) Scope:destroy() end return Screenend
return VoteA filled screen finds its elements by name, checks their class, owns every connection through its scope, and sends requests through a controller:
--!strict-- Vote screen: drives StarterGui.Vote. Expects a Panel frame holding TextButtons Map1..MapN and Close.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local Maps = require(ReplicatedStorage.Game.Config.Maps)
local Log = Logger.scope("Vote")
export type Screen = { Modal: boolean, StartsOpen: boolean, open: (self: Screen) -> (), close: (self: Screen) -> (), destroy: (self: Screen) -> (),}
local Vote = {}
local function findButton(parent: Instance?, name: string): TextButton? local Found = if parent then parent:FindFirstChild(name) else nil if Found and Found:IsA("TextButton") then return Found end Log.warn(`Panel.{name} is missing or is not a TextButton`) return nilend
function Vote.new(context: any, gui: ScreenGui): Screen local Scope = context.Kernel:createScope() local Votes = context.Controllers("VoteController") local Panel = gui:FindFirstChild("Panel") local Buttons: { [number]: TextButton } = {}
local function highlight(mapIndex: number) for Index, Button in Buttons do Button.BackgroundTransparency = if Index == mapIndex then 0 else 0.5 end end
for Index, Map in Maps do local Button = findButton(Panel, `Map{Index}`) if Button then Buttons[Index] = Button Button.Text = Map.Name Scope:add(Button.Activated:Connect(function() Votes:vote(Index) highlight(Index) end)) end end local Close = findButton(Panel, "Close") if Close then Scope:add(Close.Activated:Connect(function() context.close("Vote") end)) end
local Screen = { Modal = true, StartsOpen = false } :: any function Screen.open(_self: Screen) gui.Enabled = true -- The server's record wins over the local highlight. The request yields, so re-check the scope after it. task.spawn(function() local MyVote = Votes:myVote() if not Scope.Destroyed then highlight(MyVote) end end) end function Screen.close(_self: Screen) gui.Enabled = false end function Screen.destroy(_self: Screen) Scope:destroy() end return Screenend
return VoteVoteController (on Services and controllers) sends the vote and closes this screen when the server announces the winner. The close button is a GuiButton, which matters in a mouse-lock game (see the authoring rules below).
The context object
Section titled “The context object”Every screen of a session shares one context table:
| Member | Description |
|---|---|
context.Kernel |
The client kernel: Scheduler, Bus, Hooks, createScope, getService. |
context.Controllers(name) |
kernel:getService(name): another controller’s module table. Errors for an unknown name. |
context.open(name) |
UIController:open(name). |
context.close(name) |
UIController:close(name). |
There is no isOpen on the context; call context.Controllers("UIController"):isOpen(name).
Authoring rules
Section titled “Authoring rules”Author every ScreenGui in StarterGui, through the Roblox Studio MCP when one is connected, and screenshot the result to check the layout. Without an MCP, write the screen module anyway and list the ScreenGui tree it expects (names and classes) under Screens in docs/game/Plan.md.
| Property or practice | Rule | Why |
|---|---|---|
ResetOnSpawn |
Off | On, every respawn copies a fresh gui and rebinds the screen. UIController warns at start. |
ScreenInsets |
CoreUISafeInsets for any gui with buttons or key information |
Keeps contents clear of the Roblox top bar buttons and device cutouts. None only for a non-interactive background or fade in its own ScreenGui, since that content may sit under core UI. |
SafeAreaCompatibility |
None |
The default FullscreenExtension stretches full-screen backgrounds automatically; the engine reference advises choosing ScreenInsets per ScreenGui instead. |
ZIndexBehavior |
Sibling |
Children render above their parents and ZIndex orders siblings. |
DisplayOrder |
In its band | See the table below. |
| Text size | AutomaticSize, never TextScaled, never below 9 px |
AutomaticSize keeps one font size and resizes the element; below 9 px text is unreadable on phones. |
| Modal button | In a mouse-lock game (first person, shift lock), every modal ScreenGui holds one visible GuiButton with Modal = true |
While such a button is visible the engine does not lock the mouse. Without it the cursor stays locked and nothing in the modal can be clicked. |
AutoLocalize |
false on labels whose text comes from the Text module |
The string is already localized by Text. |
| Input | Bind every action to keyboard, gamepad and touch; hold-style handlers treat UserInputState.Cancel as release |
See InputDriver. |
| Band | DisplayOrder |
|---|---|
| HUD | 0-9 |
| Menus | 10-19 |
| Modals | 20-29 |
| Toasts | 30 and up |
A game theme is one StyleSheet at ReplicatedStorage.Design.StyleSheet, edited with Studio’s Style Editor. On every bind, UIController creates a StyleLink to it inside the gui, unless the gui already has a StyleLink as a direct child. One StyleSheet applies per tree, so an authored link always wins. Without a Design.StyleSheet, nothing is linked.