Skip to content

GUI controls ​

clientside

Clientside scripts build interface — HUDs, windows, shops, message boxes, custom chat bars — out of GUI controls: Graal-style objects such as GuiWindowCtrl, GuiButtonCtrl and GuiTextCtrl, rendered by the client's UI layer on top of everything else. This page covers creating and parenting controls, styling them, listening for mouse and keyboard events, and cleaning them up.

GUI controls are one of two ways to put things on screen. The other is script images (findimg), which are cheaper and can live in the world, but are not interactive and always draw below the GUI. Rule of thumb: anything the player clicks, types into or drags is a GUI control; decoration, floating text and effects are images.

All the declarations on this page are in the GUI controls reference.

Creating a control ​

Construct a control with a name, then assign properties:

ts
// weapons/hello.client.ts
export function onCreated() {
    const win = new GuiWindowCtrl('Hello_Window')
    win.text = 'Hello'
    win.extent = '240,120'          // or win.width = 240; win.height = 120
    win.position = `${ScreenWidth / 2 - 120},100`

    const btn = new GuiButtonCtrl('Hello_Close')
    btn.text = 'Close'
    btn.position = '20,50'
    btn.width = 80
    win.addControl(btn)

    btn.on('click', () => win.destroy())
}

Things worth knowing about this model:

  • Names are global. Every control is registered under its name and also published as a global variable, so after the code above any script can write Hello_Window.hide() — the classic GS2 style. Because the namespace is shared by every weapon, prefix control names with your weapon's name (Hello_Window, Shop_BuyButton, Messages_Text1).
  • Re-creating a name replaces the old control. new GuiWindowCtrl('Hello_Window') a second time destroys the first one (and its children) before creating the new one, so rebuilding a UI from scratch is safe.
  • Writes are batched. Creation and property writes are collected and sent to the renderer once per flush window: a control costs one create plus at most one update per flush, however many properties you assign. You don't need to batch writes yourself.
  • Reads are live once the control exists. text, x, y, width, height and visible read back from the renderer after the creating tick (so you see e.g. a label's auto-sized width, or a window the player dragged). Before that, reads return what you wrote.

position and extent are write-only "x,y" / "w,h" conveniences for x/y and width/height. show() / hide() toggle visible, and bringtofront() raises a control above its siblings. profile is accepted for GS2 compatibility but does nothing yet — style controls with the properties described below.

The control types ​

Every control derives from GuiControl, which carries position, size, visibility, skins, parenting and events.

ClassWhat it is
GuiWindowCtrlA draggable window with a title bar (text). Windows always drag by their title bar.
GuiButtonCtrlA push button with a text caption.
GuiTextCtrlA static label with font, fontsize, style and color. Auto-sizes to its text.
GuiTextEditCtrlA single-line text input with placeholder and password.
GuiPanelCtrlAn invisible grouping container; give it color / border props to draw a box.
GuiImageCtrlShows (a region of) an image asset, or a gani character via actor.
GuiScrollCtrlA scrollable viewport; children may be larger than it.
GuiStretchCtrlA container laid out at a fixed virtual resolution and scaled to its real size.

Labels ​

A GuiTextCtrl sizes itself to its text until you set width or height explicitly. The 'c' style centers text within the control's width, so set a width when centering:

ts
const title = new GuiTextCtrl('Shop_Title')
title.text = 'General Store'
title.fontsize = 24        // points, 1-256; re-rasterized, stays crisp. Default 18
title.style = 'bc'         // any of b(old), i(talic), c(entered)
title.color = '255,220,120'
title.width = 300          // needed for 'c' to have a box to center in

font defaults to 'opensans', which ships with the client; other names try the system's fonts and fall back to a built-in font. To lay out text before creating a label — word-wrapping a long message into pages, right-aligning a hint — use measuretext, which measures exactly the way a label renders.

Text inputs ​

ts
const edit = new GuiTextEditCtrl('Login_Password')
edit.placeholder = 'Password'
edit.password = true       // only takes effect in the creating tick
edit.width = 200
edit.onTextChanged = text => { this.pw = text }

password must be set in the same tick the control is created; it has no effect afterwards. The current value is always readable as edit.text.

Images and character previews ​

GuiImageCtrl displays an image asset. imagerect picks a source region, which is stretched to the control's extent — extent equal to the region size shows it 1:1, double shows it at 2x. image = '' draws a solid rectangle in the tint color (useful as a highlight overlay). scale zooms the whole control while keeping width/height and event coordinates in unscaled units.

Setting actor draws a gani character instead — ideal for an outfit preview in a tailor or character screen. GuiActor fields are ani, head, body, colors, dir, x and y:

ts
const pic = new GuiImageCtrl('Tailor_Preview')
pic.extent = '32,48'
pic.scale = 2                        // shows 64x96
pic.actor = { ani: 'idle', dir: 'down', y: 16 }
pic.actor.colors = player.colors     // copy the local player's colors
pic.actor.colors[1] = '255,0,0'      // then recolor the coat
// pic.actor = null brings the plain image back

The gani's origin sits at the control's top-left (shifted by actor.x/y) and anything outside the extent is cut off; a stock idle character fits in '32,48' with actor.y = 16. Gani sounds don't play.

Scrolling and stretching containers ​

Children of a GuiScrollCtrl land in an inner content area; scrollbars appear when the content is larger than the viewport and the mouse wheel scrolls it.

A GuiStretchCtrl lets you design a screen once at a fixed resolution and have it fill any window size. Children are positioned in virtual units, and stretchmode picks 'stretch' (fill, may distort), 'fit' (uniform, letterboxed) or 'fill' (uniform, cropped):

ts
export function onCreated() {
    const ui = new GuiStretchCtrl('Battle_Root')
    ui.virtualextent = '720,480'
    ui.stretchmode = 'fit'
    ui.extent = `${ScreenWidth},${ScreenHeight}`
    this.lastW = ScreenWidth; this.lastH = ScreenHeight
    // ...build children in 720x480 coordinates
}

export function onUpdate() {
    // There are no anchors: re-assert the extent when the window resizes.
    if (ScreenWidth !== this.lastW || ScreenHeight !== this.lastH) {
        this.lastW = ScreenWidth; this.lastH = ScreenHeight
        Battle_Root.extent = `${ScreenWidth},${ScreenHeight}`
    }
}

Positions, sizes, image scale, actors, label fontsize and panel borders scale. Text and borders use the smaller axis factor so they never distort. Themed chrome — window title bars, default button height, scrollbar thickness, button and text-edit text — keeps its native pixel size. Inside the container, reads of x/y/width/height and event x/y are in virtual units, while absx/absy stay real screen pixels.

Parenting and layout ​

New controls attach to the screen root by default — no window required. Call addControl on a container to parent a child inside it:

  • Children are positioned relative to the parent's top-left. For a window that includes the title bar (GS2-style), so start content around y = 40.
  • Destroying, hiding or moving a parent takes its children with it; bringtofront() raises the whole subtree.
  • To hit-test the mouse against a control wherever it sits (inside a dragged window, a scrolled area, a stretch container), use the live screen-absolute absx/absy, which compare directly with mousex()/mousey().

GameControl ​

GameControl is a pseudo-control for the game screen itself (a GuiGameScreen). It exposes the live viewport width, height and extent, and GameControl.addControl(ctrl) makes root parenting explicit — or moves a control back out of a window. Use it for HUDs and custom chat bars that sit directly on the game view:

ts
const bar = new GuiTextEditCtrl('Chat_Edit')
bar.width = 400
bar.y = GameControl.height - 30      // bottom-anchored: no anchor API,
GameControl.addControl(bar)          // so reposition in onUpdate on resize

GameControl cannot be created, destroyed, styled or listened to ('GameControl' is a reserved name — constructing a control with it throws), and its show/hide/bringtofront are no-ops.

Draw order ​

Among siblings, the last one drawn wins: controls created later draw above earlier ones, and bringtofront() moves one to the top of its siblings. A common pattern is a full-screen dimmer panel created before a modal window so it sits underneath it.

Finding controls ​

Because every control is also a global, most code simply refers to My_Window directly. When the name is dynamic, or the control may not exist:

  • findGuiControl(name) returns the control, or undefined.
  • findobj(name) is the Graal-style alias. It is typed as never returning undefined so chaining stays terse (findobj('Shop_Window').visible = true), but it still returns undefined at runtime for unknown names — use findGuiControl when existence is in doubt.

Both return GameControl for the name 'GameControl'.

Styling ​

Controls use a default themed look until you style them.

Panels are invisible until given a fill or border:

ts
const box = new GuiPanelCtrl('Hud_Box')
box.extent = '160,40'
box.color = '0,0,0,160'      // r,g,b[,a] 0-255
box.bordersize = 2
box.bordercolor = '255,255,255'
box.borderradius = 6         // rounds fill and border (children aren't clipped)

Windows take a color tint multiplied into their frame.

Skins replace the themed look of windows, buttons, panels, text edits and scroll areas with a 9-slice image asset: corners render 1:1, edges and center stretch. Labels and image controls ignore skins.

PropertyMeaning
skinImage asset, e.g. 'gui/fancywindow.png'; '' restores the default look.
skinrect"left,top,width,height" sub-region of the texture (atlas support).
skinborderBorder thickness in source pixels; unset = a third of the region, 0 = plain stretch.
skinrecthover / skinrectpressedButton-only per-state regions. Without them a skinned button auto-lightens/darkens on hover/press.
ts
const win = new GuiWindowCtrl('Messages_Window')
win.skin = 'itemui.png'
win.skinrect = '0,0,96,96'
win.skinborder = 16

Skins load asynchronously

Skin images go through the asset cache, so the first use of an image may pop in a moment after the control appears; later uses of an already-loaded image apply instantly. If the asset can't be found, the control keeps its default look.

Events ​

Listen with on(event, listener) and remove with off(event, listener?) (omit the listener to remove all of them for that event). on returns the control, so calls chain. Every control kind supports every event, and multiple listeners run in registration order.

The events and their payloads are listed in GuiEventMap:

EventPayloadNotes
click, doubleclickGuiButtonEventPress and release on the same control, any button.
mousedownGuiButtonEvent
mouseupGuiButtonEventFires on the control(s) the press started on, even if the cursor moved off.
mousemove, mouseenter, mouseleaveGuiMoveEvent
mousewheelGuiWheelEventdelta in notches, positive = up.
keydown, keyupGuiKeyEventWhile the control or a child has keyboard focus.

Button payloads carry button — a GuiMouseButton: 'left', 'right' or 'middle' — so one click listener can handle right-clicks too. Mouse x/y are relative to the control's top-left (in unscaled/virtual units inside scaled images and stretch containers).

ts
Inventory_Slot3
    .on('click', e => {
        if (e.button === 'right') dropItem(3)
        else useItem(3)
    })
    .on('mouseenter', () => { Inventory_Tooltip.visible = true })
    .on('mouseleave', () => { Inventory_Tooltip.visible = false })

Bubbling ​

Mouse events bubble: a click on a child (an image inside a panel, a button inside a window) fires on the child first and then on each listening ancestor. This lets one listener on a container handle clicks on everything inside it; use the event's coordinates or your own state to tell children apart.

Keyboard events ​

keydown/keyup are routed by focus, not by the cursor: they fire while the control — or any child of it — has keyboard focus (click a text edit to focus it), then bubble to listening ancestors. The GuiKeyEvent key uses the same names as the global keydown() helper ('a', 'enter', 'escape', '0'…'9'), plus shift and control modifier flags. They are edge-triggered (one event per press, no auto-repeat). Keys still reach the control itself, so typing letters into a listening text edit works as usual.

ts
const edit = new GuiTextEditCtrl('Cmd_Edit')
edit.on('keydown', e => {
    if (e.key === 'enter') {
        triggerServer('weapon', this.name, 'command', edit.text)
        edit.text = ''
    }
})

When nothing script-owned has focus (including while the built-in chat bar is focused), no key events fire. For global hotkeys, poll keydown() instead.

Listening makes a control count as UI

A control that listens for any mouse event becomes hit-testable, so hovering it makes mouseonui() return true. That's usually what you want — it stops world clicks from firing through your UI. Keyboard-only listeners don't change this.

Legacy handler properties ​

The older assignment-style handlers still work but are deprecated in favour of .on(): onAction on buttons (use on('click')) and onClick on images (use on('mousedown')). onTextChanged on text edits has no .on() equivalent and is the way to observe edits.

Lifecycle and cleanup ​

  • destroy() removes a control and every child added via addControl for good. Writing to a destroyed control throws.
  • Controls are destroyed automatically when the weapon that created them is unloaded or reloaded — including hot reloads — so a weapon never has to clean up after itself on removal.

Build UI in onCreated or event handlers, not timers

Controls created from setTimeout/setInterval callbacks are only swept when the player disconnects, not when the weapon unloads. Create controls in onCreated, onUpdate, GUI event handlers or other script events; timers may freely modify existing controls (animating text, fading a panel's color).

A typical show/hide pattern keeps one set of controls and toggles visible, or destroys the root window and rebuilds it next time (safe, because re-creating a name replaces it). See the HUD & message box tutorial for a complete example.

Naming convention summary ​

  • Weapon_Thing — weapon name, underscore, role: Shop_Window, Shop_BuyButton, Messages_Background.
  • Keep names stable so other scripts (and findobj) can address them.
  • Never use GameControl.