xopoiii/hudblox
HUD capsules and round buttons that look like Roblox's own top bar, placed by the device's real safe zones
HudBlox
HUD capsules and round buttons that look like Roblox's own top bar, placed by the device's real safe zones.
HudBlox draws a game's counters as the dark capsules Roblox draws its own menu and chat buttons as, on the same line, in the same plate, at the same sizes. It puts them where the device and Roblox leave room: never under a notch, never under Roblox's buttons, never under the thumbstick. The sizes were not designed: they were read off a live client's top bar, and the kit was built and used in a live game before it became a package.
Status: 0.3.0. The placement arithmetic is proven by specs that run on every push, and each of 77 small slips in it makes the suite fail (
tests/Mutate.luau). The modules that build Instances are checked against the Roblox API by the type gate, under both type solvers, and were ported from code that runs in a live game; as a package they have not yet been run in a game or looked at on devices. Try it in a test place first.
Install
pesde add xopoiii/hudblox -t roblox -a HudBlox
or pin it exactly in pesde.toml:
[dependencies]
HudBlox = { name = "xopoiii/hudblox", version = "=0.3.0", target = "roblox" }
HudBlox runs on the client. It has no dependencies.
The top line in a minute
local HudBlox = require(path.to.HudBlox)
-- The game's icons stay in the game: the kit is told how to draw one by name.
local theme: HudBlox.Theme = {
icon = HudBlox.Icons.resolver({
ids = { coin = "rbxassetid://...", power = "rbxassetid://...", shop = "rbxassetid://...", bag = "rbxassetid://..." },
}),
}
-- A ScreenGui inside the device's safe insets, edge to edge, so the row can share Roblox's line.
local gui = HudBlox.SafeArea.screen(playerGui, "Hud")
local row = HudBlox.TopBar.mount(gui, theme)
-- The right row fills left to right and hugs the right edge: the last thing added is in the corner.
local shop = row.addButton("shop")
local wallet = row.addPill({
{ lead = "coin", pin = { "999.9K", "888.8M" } }, -- pinned: the row does not jitter as it ticks
{ lead = "power", pin = { "9999" } },
})
-- One round button on the left, 8 past Roblox's own, read from GuiService.TopbarInset.
local bag = row.addLeftButton("bag")
wallet.segments[1].value.Text = "120"
wallet.segments[2].value.Text = "7"
shop.Activated:Connect(openShop)
bag.Activated:Connect(openBag)
The row fits itself again whenever the screen or Roblox's inset changes, and it warns in the
output if it wraps: a row that does not fit is not clipped, it grows a second line where nobody
looks. Ask ahead of time with Layout.fits (below).
The API
HudBlox is a table of modules. Sizes are pixels; Measure holds the measured numbers.
Theme: what the game hands in
type Theme = {
icon: (name: string, parent: Instance) -> GuiObject, -- required
plate: Color3?, -- default: Roblox's own, RGB 18, 18, 21
plateTransparency: number?, -- default: 0.08
font: Font?, -- default: Builder Sans Bold
text: Color3?, -- default: white
press: (() -> ())?, -- called on the frame a round button is pressed: a click sound
}
Icons.resolver(options): Icon builds icon from the game's table:
type IconOptions = {
ids: { [string]: string } | (name: string) -> string?, -- "rbxassetid://..." by name
tints: { [string]: Color3 }?, -- the disc's colour for a name with no image yet
pixelated: boolean?, -- true for pixel art: not filtered when scaled
smooth: { [string]: boolean }?, -- names filtered anyway: line glyphs among pixel art
color: Color3?, -- a colour over every image: white art in the theme's ink
}
A name with no id draws a plain disc, so a missing picture never breaks a screen.
TopBar: the row
TopBar.mount(gui: ScreenGui, theme: Theme): Row
Row | |
|---|---|
addPill(parts: { Part }, tappable: boolean?): (Capsule, UIScale) | A capsule in the right row, one segment per part. The UIScale is yours to animate: a pulse does not move the row. tappable makes the plate a TextButton. |
addButton(icon: string): TextButton | A round button in the right row. |
addLeftButton(icon: string): TextButton | A round button on the left, past Roblox's own buttons. |
fit() | Places everything again. The row calls it itself on every screen or inset change. |
destroy() | Disconnects and destroys the row and its left buttons. |
frame: Frame | The row's frame. |
For the row to share the line of Roblox's own buttons the gui must ignore the gui inset
(SafeArea.screen makes one). In a gui that respects the inset the row sits below Roblox's buttons,
reserves nothing for them, and the first left button starts at the edge margin, 16.
Pill: a capsule
type Part = {
lead: string?, -- the icon's name; nil or "" for no lead
emoji: boolean?, -- `lead` is a text glyph to show as it is
pin: { string }?, -- the widest texts the value can hold: its width is pinned to the widest
}
type Segment = { value: TextLabel, lead: GuiObject? }
type Capsule = { plate: GuiObject, segments: { Segment } }
Pill.build(parent, theme, parts: { Part }, tappable: boolean?): Capsule: a capsule anywhere.Pill.segment(plate, theme, order: number, part: Part): Segment: one more segment, later.Pill.pinWidth(label: TextLabel, candidates: { string }): pins a label to its widest text. Padding the string does not do this: the font is not monospaced, so "01:24" and "02:12" differ in width.Pill.measure(font, size, candidates, done: (widest: number) -> ()): the measurement alone.
Button: a round button
Button.round(parent, theme, icon: string, options: ButtonOptions?): TextButton
type ButtonOptions = {
name: string?, -- default "IconButton"
size: number?, -- default 44
iconPx: number?, -- default 24, the ink Roblox draws
iconFraction: number?, -- the icon as a fraction of the plate instead (Measure.ICON_FRACTION)
}
It squashes to 0.9 on press and has no hover state. Connect Activated.
SafeArea: the screen
SafeArea.screen(parent, name): ScreenGui | The HUD's gui: kept across respawns, inside the device's safe insets, over Roblox's strip. |
SafeArea.topStrip(gui): number | The height of Roblox's strip at the top of gui; 0 for a gui that respects the inset. |
SafeArea.usableHeight(gui): number | The height below that strip. |
SafeArea.y(gui, fraction): UDim | fraction of the usable height: use it where UDim.new(fraction, 0) would go. |
SafeArea.insetMinX(): number | Where Roblox's buttons end: GuiService.TopbarInset.Min.X. |
SafeArea.topbarFreeX(gui): number | The same, in gui's coordinates. |
SafeArea.chromeFreeX(gui, rowTop): number | What a row at rowTop must leave for Roblox; 0 below its line. |
SafeArea.measure(gui): Screen | gui as Layout takes it. |
SafeArea.bind(gui, place): () -> () | Runs place now and on every change of the area; returns the stop. |
The bottom corners
A read-out in a bottom corner stands above the thumbstick and the jump button by their measured height, not by a guess from the device type:
local MARGIN = 12
local card = HudBlox.Indicator.capsule(gui, "Clock", Vector2.new(1, 1), theme)
local function place()
card.Position = UDim2.new(1, -MARGIN, 1, -HudBlox.TouchClearance.base(MARGIN))
end
local refresh, stop = HudBlox.Indicator.follow({
gui = gui,
frames = { card },
bottom = function() return HudBlox.TouchClearance.base(MARGIN) end,
place = place,
busy = function() return dialogsOpen > 0 end, -- optional; call refresh() when it changes
})
TouchClearance.height(): number: pixels from the screen's bottom to the top of the highest visible touch control; 0 without touch controls.TouchClearance.base(margin): number: how far above the bottom edge a read-out's lower edge goes.TouchClearance.follow(changed): () -> (): callschangedwhen the controls arrive, move or hide.Indicator.capsule(parent, name, anchor: Vector2, theme): Frame: a read-out's plate, 36 high and half as solid as a top-bar capsule; put icons and text in it (Indicator.TEXT_SIZE,HEIGHT,STACK,PAD).Indicator.follow(options): (refresh, stop): keeps a read-out placed, and hides its gui while the game isbusyor while something reported toBandreaches under it.Band.set(key, rect: { left, right, top }?),Band.lift(left, right, bottom): number,Band.has(key),Band.onChange(changed): () -> (): the game's own bottom furniture (a hotbar) reports its rectangle; a card asks how much higher it must stand to clear it.
Layout and Clearance: the arithmetic
No Instances, so a game can put its own worst case in a spec:
local Layout = HudBlox.Layout
local top = Layout.top({ width = 801, left = 0, top = -58 }, 208, 1) -- screen, TopbarInset.Min.X, left buttons
-- top.lefts = { 216 }, top.rowLeft = 268, top.rowWidth = 517, top.onChromeLine = true
local needs = Layout.rowNeeds({
Layout.pillWidth({ { ink = 44, lead = true }, { ink = 44, lead = true } }), -- 186
44, -- a round button
})
assert(Layout.fits(top, needs))
Layout: top, fits, rowNeeds, pillWidth, leftStart, onChromeLine, freeX,
chromeFreeX, topStrip, usableHeight, yOffset, pinned, mirrorX.
Clearance: height(bottom, controls: { { top: number, visible: boolean } }), base(margin, height),
stacked(base, index, size, gap).
Native and Rtl
Native: Roblox's menu look. Colours (SURFACE,TILE,PRIMARY,CAPSULE,TEXT,BODY,DIVIDERand their transparencies), sizes (CORNER,PAD,GAP,BUTTON_H,TITLE_SIZE,BODY_SIZE),Native.font(weight?),Native.text(parent, text, size, { weight?, color?, align? }?),Native.button(parent, text, "primary" | "secondary"?),Native.iconButton(parent, glyph, ring: GuiObject?). A line starts where the game's reader starts unlessalignsays otherwise (Host), and a round button wears the kit's round focus ring unless one is handed in.Rtl.reader(isRtl: () -> boolean): RtlReader: the mirror for a right-to-left locale, switched by the game's own answer:apply(root),flipEdge(frame),slot(index),start(),active(). The mechanics areRtl.flipX,Rtl.flipListAlign,Rtl.alignText,Rtl.mirror.
Host: what there is one of
HudBlox.Host.set({
rtl = HudBlox.Rtl.reader(function() return myLocaleIsRtl end),
padUsed = function(act) countPadUse(act) end,
})
A capsule takes its theme as an argument, because a game may draw two looks. Which way its reader
reads and who counts a gamepad's presses are one for the whole client, and the dialog, the focus
and Native.text read them here. Nothing is required: unset, the reader reads left to right and
nobody counts. padUsed hears "Panel", "Back", "Hud" and "Bag".
Dialog: a whole dialog
local shop = HudBlox.Dialog.new(playerGui, "Shop", "Shop", 620, 600, {
bottom = { band = 82, room = 360 }, -- a hotbar the panel keeps above
cover = function(covered) hideHotbar(covered) end,
})
HudBlox.Native.button(shop.footer, "Buy")
openButton.Activated:Connect(shop.toggle)
Dialog.new(playerGui, name, title, width, height, options?) builds a ScreenGui (DisplayOrder 10)
with a backdrop that dims the world, a nearly solid panel, a title, a close button on the left as in
the Escape menu, a divider, a body to fill and a footer that takes a button's height once it
holds one. It hands back gui, panel, title, body, footer, setOpen(open), toggle() and
changed (a BindableEvent fired with the new state).
- It closes on the X, on a tap outside and on a gamepad's B, pops in and shrinks out.
- While it is up the touch controls are hidden and the character stands (
TouchControls), a gamepad's focus stays inside the panel (Focus), and the PlayerGui attributeModalsOpencounts it (Dialog.OPEN_ATTRIBUTE), for whatever steps aside for a dialog. - Its size is
DialogGeometry.layout(width, height, strip, maxW, maxH, bottom?), plain arithmetic with a spec for every rule: framed where the screen has room, under Roblox's strip where it is short, full height on a landscape phone, never over the top row, and above the game's bottom band (bottom) whileroomis left over it. Where it is not,clearsBottomis false andcover(true)tells the game to hide the band until the dialog closes.
Focus, Ring, Pick, Stack: a gamepad's selection
Focus.push(name, root, { back?, first? }?)gives the pad to a panel: the selection is kept insideroot, B runsbackfor the newest panel alone, the world's prompts are off and the character stands.Focus.pop(name)takes it back,Focus.open()says whether any panel has it. Only while the player is on a gamepad (PadInput): a mouse or a finger sees no ring.Focus.start()switches the engine's own auto-selection off and begins to follow the selection. The firstpushcalls it; a game that wants the engine's auto-selection off from its first frame calls it as its client loads. A game that never pushes keeps the engine's own behaviour.Ring.square()andRing.round(): the white outline of the focused control, in place of the engine's blue box.Ring.install(playerGui)(which the firstpushdoes) gives every control the square one; a round button names the round one itself.Pick.first(targets, rtl)andPick.nearest(targets, x, y, rtl): where focus starts in a panel and where it goes when the focused control is gone.Stack: which panels hold the focus, a name standing once. Both are plain data with specs.
PadInput, PadGlyph, PadMenu, TouchControls
PadInput.active()andPadInput.onChange(fn) -> stop: whether the player is on a gamepad now (UserInputService.PreferredInput). In Studio only,Workspace:SetAttribute("PadCheck", true)answers yes, for a test bridge whose pad keys arrive as a keyboard's.PadGlyph.cap(parent, keyCode, size): the picture of a pad's button as the player's own pad draws it, shown only while they are on a pad.PadMenu.bind({ gui, buttons, first, bag, toggleBag }): D-pad down puts the focus on a top bar's buttons, D-pad right opens the game's most used panel. Both stand down while a panel has the focus.TouchControls.set(reason, hide): hides the thumbstick and the jump button and holds the character, keyed by reason so two surfaces can overlap.
What stays in the game
Its icon ids, its locale and its text direction, what its dialogs hold, its bottom furniture, and every colour beyond Roblox's measured plate. A kit module reads nothing of the game it is in.
The measurements
Read off CoreGui's TopBarApp in a live client (last on 2026-09-22 and 2026-09-23):
| What | Value |
|---|---|
| Capsule and round button | 44 high, fully round |
| Plate | RGB 18, 18, 21 at transparency 0.08 |
| Icon | 24 (inside a 36 hit area) |
| Number | Builder Sans Bold, 22, white |
| Inside a capsule | 10 before the lead, 6 between lead and number, 16 between segments, 12 after |
| The line | 16 from the screen's edge, 12 from the top, 8 between things |
| Roblox's strip | 58 tall on a phone; its buttons ended at x = 208 there (TopbarInset.Min.X), which moves |
Contributing
rokit install # the pinned toolchain
lefthook install # the gates, before every commit and push
sh scripts/run-tests.sh # the specs
luneblox run tests/Mutate --yes # every slip in the arithmetic must fail the suite
lefthook run pre-commit --all-files # every pre-commit gate over the whole tree
Specs run on LuneBlox, which runs the Luau version and fast flags Roblox runs.
License
MIT. See LICENSE.