Skip to content

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.

UIController.start does the following:

  1. Collects every ModuleScript directly inside Game/Screens, keyed by name. Without a Screens folder, UIController does nothing.
  2. For each module, checks StarterGui.<Name>. When the original has ResetOnSpawn on, it warns: [UIController] StarterGui.<Name> has ResetOnSpawn on: set it off in Studio or the screen is rebuilt on every respawn.
  3. Watches PlayerGui.ChildAdded and binds every child already present. If PlayerGui does not exist yet, it waits for it on LocalPlayer.ChildAdded.
  4. 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:

  1. A child whose name matches no module is ignored. A matching child that is not a ScreenGui warns and is skipped.
  2. If a screen with that name is already bound (the previous copy), it is unbound first: its destroy runs.
  3. The theme is linked (see Theme).
  4. The module is required and Module.new(context, gui) builds the screen object.
  5. The gui’s Destroying is connected, so destroying the copy unbinds the screen and keeps its open state for the next copy.
  6. The screen opens if it was open or had a pending open before, or if this is its first bind and StartsOpen is true. Otherwise its close runs, so every screen starts in a known state whatever Enabled was in Studio.

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 PlayerGui
if UI:isOpen("Shop") then
UI:close("Shop")
end

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

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.

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:

src/Client/Game/Screens/Vote.luau (scaffolded)
--!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 Screen
end
return Vote

A filled screen finds its elements by name, checks their class, owns every connection through its scope, and sends requests through a controller:

src/Client/Game/Screens/Vote.luau
--!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 nil
end
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 Screen
end
return Vote

VoteController (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).

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

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.