Skip to content

2. HUD & message box ​

In this tutorial you build two pieces of UI that most games need:

  • A HUD (hud weapon) in the top-right corner: a health bar that eases smoothly when your health changes, and a gold counter. The numbers belong to the server and are stored in clientr flags, so the HUD can display them but never change them. Pressing H starts a pretend fight, so you can watch the values change.
  • A message box (messagebox weapon): a window that word-wraps long text, splits it into pages, and lets the player page through with buttons, the mouse wheel or the keyboard. Any script can open it, whether serverside or clientside. New players see a welcome message once.

What you'll learn

  • Server-owned player state in clientr flags, and why the HUD reads them instead of keeping its own numbers.
  • Drawing screen-space UI with findimg: rectangles, polygons and text, plus redrawing only what changed.
  • Building a window from GUI controls (GuiWindowCtrl, GuiTextCtrl, GuiButtonCtrl) and wiring it up with .on() events.
  • Pagination with measuretext.
  • Keyboard input with onKeyPressed, and how it differs from focus-routed .on('keydown').
  • Letting other scripts drive a weapon through triggerClient and findweapon.

Files: weapons/hud.ts, weapons/hud.client.ts and weapons/messagebox.client.ts in docs/examples/hud-and-messages/.

Two ways to draw UI ​

The client offers two drawing systems, and this tutorial uses one of each:

Script images (findimg)GUI controls (new Gui…Ctrl)
Good forHUDs, overlays, world effectswindows, buttons, text fields, lists
Inputnone (poll mousex() yourself).on('click'), .on('mousewheel'), …
Positionworld tiles or screen pixelsscreen pixels, relative to the parent
Drawsbelow all GUIabove images

A HUD is display-only and should never steal clicks, so it uses images. The message box needs buttons, so it uses controls. The guide pages Images, text & particles and GUI controls cover each system in depth.

Part 1: the HUD ​

Step 1: the server owns the numbers ​

The HUD's serverside half makes sure every account has health and gold:

ts
const START_MAX_HP = 10
const START_GOLD = 25

// Seed the stats the first time an account logs in. clientr flags persist
// in the account file, so returning players keep their values.
export function onPlayerJoined(player: Player) {
    if (typeof player.clientr.maxhp !== 'number') player.clientr.maxhp = START_MAX_HP
    if (typeof player.clientr.hp !== 'number') player.clientr.hp = player.clientr.maxhp
    if (typeof player.clientr.gold !== 'number') player.clientr.gold = START_GOLD
}

player.clientr is the right bag for this kind of state:

  • only the server can write it, so a modified client can't give itself gold;
  • the player's client can read it (as clientr.gold), and every server write is synced to it immediately;
  • it's saved in the account file, so it survives logouts and restarts.

onPlayerJoined runs on every serverside script that exports it, including weapon halves, so the hud weapon can seed its own flags without touching login.ts. See Flags.

Step 2: lay out the images ​

Each piece of the HUD is one script image, identified by a number that is private to this weapon:

ts
const IMG_PANEL = 1
const IMG_BAR_BACK = 2
const IMG_BAR_FILL = 3
const IMG_HP_TEXT = 4
const IMG_COIN = 5
const IMG_GOLD_TEXT = 6

const PANEL_W = 180
const PANEL_H = 58
const MARGIN = 12
const BAR_W = PANEL_W - 20

// Places every HUD image relative to the top-right corner. Screen-mode
// images use pixel coordinates from the window's top-left.
function layout() {
    const left = ScreenWidth - PANEL_W - MARGIN
    const top = MARGIN

    const panel = findimg(IMG_PANEL)
    panel.screen = true
    panel.layer = 0
    panel.image = ''              // '' = a solid rectangle in the tint color
    panel.tint = '20,20,30'
    panel.alpha = 0.7
    panel.x = left
    panel.y = top
    panel.width = PANEL_W
    panel.height = PANEL_H

    const back = findimg(IMG_BAR_BACK)
    back.screen = true
    back.layer = 1
    back.tint = '70,20,20'
    back.x = left + 10
    back.y = top + 10
    back.width = BAR_W
    back.height = 14

    const fill = findimg(IMG_BAR_FILL)
    fill.screen = true
    fill.layer = 2
    fill.tint = '220,50,60'
    fill.x = left + 10
    fill.y = top + 10
    fill.height = 14

    const hpText = findimg(IMG_HP_TEXT)
    hpText.screen = true
    hpText.layer = 3
    hpText.style = 'bc'           // bold, centered on x
    hpText.fontsize = 12
    hpText.textshadow = true
    hpText.x = left + 10 + BAR_W / 2
    hpText.y = top + 9

    // A little diamond-shaped "coin": polygon vertices are screen pixels here.
    const coin = findimg(IMG_COIN)
    coin.screen = true
    coin.layer = 1
    coin.tint = '240,200,60'
    const cx = left + 18
    const cy = top + 41
    coin.polygon = [cx, cy - 7, cx + 6, cy, cx, cy + 7, cx - 6, cy]

    const goldText = findimg(IMG_GOLD_TEXT)
    goldText.screen = true
    goldText.layer = 1
    goldText.style = 'b'
    goldText.fontsize = 14
    goldText.textshadow = true
    goldText.tint = '255,230,140'
    goldText.x = left + 32
    goldText.y = top + 32

    this.laidOutFor = ScreenWidth
    this.shownHp = -1             // force a redraw of the values
    this.shownGold = -1
}
  • screen = true makes x/y screen pixels, measured from the window's top-left. The image ignores the camera and draws above the world, but still below GUI controls.
  • With image = '' (the default), an image with width/height draws a solid rectangle in its tint color. That's all a health bar needs.
  • The coin is a polygon: a flat list of x,y pairs, in screen pixels because the image is in screen mode.
  • layer orders images: the panel sits at 0 and the text at 3.
  • There is no anchoring API. The layout is computed from ScreenWidth and layout() remembers which width it used, so onUpdate can re-run it when the window is resized.

Step 3: update from the flags ​

ts
export function onCreated() {
    this.barHp = Number(clientr.hp) || 0
    layout()
}

export function onUpdate(dt: number) {
    // There is no anchoring API: re-run the layout when the window resizes.
    if (this.laidOutFor !== ScreenWidth)
        layout()

    const maxhp = Math.max(1, Number(clientr.maxhp) || 1)
    const hp = Math.max(0, Math.min(maxhp, Number(clientr.hp) || 0))
    const gold = Number(clientr.gold) || 0

    // The bar eases toward the real value so hits are easy to see.
    const diff = hp - this.barHp
    this.barHp = Math.abs(diff) < 0.01 ? hp : this.barHp + diff * Math.min(1, dt * 8)
    findimg(IMG_BAR_FILL).width = Math.round(BAR_W * this.barHp / maxhp)

    // Only touch the text when a value changed: every property write is a
    // (batched) host command, so skipping no-op writes keeps frames cheap.
    if (hp !== this.shownHp || maxhp !== this.shownMax) {
        this.shownHp = hp
        this.shownMax = maxhp
        findimg(IMG_HP_TEXT).text = `${hp} / ${maxhp}`
    }
    if (gold !== this.shownGold) {
        this.shownGold = gold
        findimg(IMG_GOLD_TEXT).text = `${gold} gold`
    }
}

clientr is a live, read-only mirror. onUpdate reads it every frame, but it only writes image properties when a value actually changed. Image writes are batched host commands, so skipping no-op writes keeps the frame cheap. The bar's width is the exception: it eases toward the real value over a few frames, so hits and heals are easy to see.

Notice what the HUD does not do: it never tracks the player's health itself. Anything that changes clientr.hp, whether this demo, the potion from tutorial 3 or the blaster from tutorial 4, shows up with no extra wiring.

Step 4: a way to test it ​

ts
// onKeyPressed fires once per press (no auto-repeat) and stays silent while
// the chat bar or a text field has focus - so typing "h" in chat is safe.
export function onKeyPressed(key: string) {
    if (key === 'H')
        triggerServer('weapon', this.name, 'demo')
}

onKeyPressed fires once per key press, with no auto-repeat. It stays silent while the chat bar or a text field has focus, so typing "h" in chat doesn't trigger it. Letter keys arrive uppercase ('H'), and other keys by name ('Space', 'Escape', 'Left').

On the server, the demo action picks the outcome and writes the flags:

ts
// A stand-in for real gameplay: pressing H asks the server to "fight".
// The server decides the outcome and writes the flags; the HUD just reacts.
export function onActionServerSide(player: Player, action: string) {
    if (action !== 'demo') return

    const maxhp = Number(player.clientr.maxhp) || START_MAX_HP
    const hp = Number(player.clientr.hp) - (1 + Math.floor(Math.random() * 3))
    const loot = Math.floor(Math.random() * 10)
    player.clientr.gold = (Number(player.clientr.gold) || 0) + loot

    if (hp > 0) {
        player.clientr.hp = hp
        player.chat = loot > 0 ? `Found ${loot} gold!` : 'Ouch!'
        return
    }

    // Knocked out: refill and tell the player why, using the message box
    // weapon's clientside half. We are inside onActionServerSide, so
    // triggerClient knows which player to answer.
    player.clientr.hp = maxhp
    triggerClient('weapon', 'messagebox', 'show', 'Knocked out',
        'You collapse in a heap. A passing healer patches you up and sends you on '
        + 'your way.\n\nYour health has been fully restored, and you kept all of '
        + `your gold (${player.clientr.gold} in total). Try to be more careful next time!`)
}

When health runs out, it opens a message box on the player's client with triggerClient('weapon', 'messagebox', 'show', …). triggerClient can target any weapon the player has, not just the weapon that sent the trigger.

Part 2: the message box ​

Step 5: build the window once ​

ts
const WIN_W = 420
const WIN_H = 250
const PAD = 16
const TOP = 40                // window children start below the title bar
const FONT_SIZE = 16
const BUTTON_W = 90
const BUTTON_H = 30
const BODY_H = WIN_H - TOP - BUTTON_H - PAD * 2

// Builds the window once, hidden. Showing/hiding existing controls is
// cheaper than recreating them, and nothing flickers.
function build() {
    const win = new GuiWindowCtrl('MsgBox_Window')
    win.extent = `${WIN_W},${WIN_H}`
    win.visible = false

    const body = new GuiTextCtrl('MsgBox_Body')
    body.position = `${PAD},${TOP}`
    body.fontsize = FONT_SIZE
    win.addControl(body)

    const pageLabel = new GuiTextCtrl('MsgBox_Page')
    pageLabel.position = `${PAD},${WIN_H - PAD - BUTTON_H + 6}`
    pageLabel.fontsize = 12
    pageLabel.color = '180,180,190'
    win.addControl(pageLabel)

    const prev = new GuiButtonCtrl('MsgBox_Prev')
    prev.text = 'Back'
    prev.position = `${WIN_W - PAD - BUTTON_W * 2 - 8},${WIN_H - PAD - BUTTON_H}`
    prev.extent = `${BUTTON_W},${BUTTON_H}`
    win.addControl(prev)

    const next = new GuiButtonCtrl('MsgBox_Next')
    next.position = `${WIN_W - PAD - BUTTON_W},${WIN_H - PAD - BUTTON_H}`
    next.extent = `${BUTTON_W},${BUTTON_H}`
    win.addControl(next)

    this.win = win
    this.body = body
    this.pageLabel = pageLabel
    this.prevButton = prev
    this.nextButton = next
    listen(win, prev, next)
}
  • Controls are created with new. The name you pass is global across all weapons, so prefix it with your weapon's name (MsgBox_…).
  • addControl parents a control inside the window, with positions relative to the window's top-left. That area includes the title bar, so content starts at about y = 40.
  • extent and position are "w,h" and "x,y" shorthands.
  • The window is built once, hidden, and then shown and hidden as needed. That's cheaper than rebuilding controls for every message and avoids flicker. Controls are destroyed automatically when the weapon unloads.

Step 6: mouse events ​

ts
// .on() listeners are plain callbacks. Arrow functions inside a top-level
// function still see the weapon as `this`.
function listen(win: GuiWindowCtrl, prev: GuiButtonCtrl, next: GuiButtonCtrl) {
    prev.on('click', () => turnPage(-1))
    next.on('click', () => {
        if (this.page < this.pages.length - 1) turnPage(1)
        else close()
    })
    // Events bubble from child to parent: a wheel turn over the body label
    // reaches the window's listener too.
    win.on('mousewheel', e => turnPage(e.delta > 0 ? -1 : 1))
}

.on(event, listener) works on every control. Mouse events bubble: a wheel turn over the body label fires the window's mousewheel listener too. GuiWheelEvent.delta is positive when scrolling up.

The listeners are arrow functions created inside a top-level function, so this is still the weapon.

Step 7: paginate with measuretext ​

ts
// Exported, so other weapons can call it through findweapon(...).trigger.
export function showMessage(title: string, text: string) {
    if (this.open) {
        // One window at a time: queue the rest.
        this.queue.push([title, text])
        return
    }

    // Wrap to the body width, then cut the wrapped lines into pages that
    // fit the body height. measuretext wraps exactly like a label would.
    const measured = measuretext(text, { fontsize: FONT_SIZE, width: WIN_W - PAD * 2 })
    const perPage = Math.max(1, Math.floor(BODY_H / measured.lineheight))
    const pages: string[] = []
    for (let i = 0; i < measured.lines.length; i += perPage)
        pages.push(measured.lines.slice(i, i + perPage).join('\n'))
    if (pages.length === 0) pages.push('')

    this.pages = pages
    this.page = 0
    this.open = true

    const win: GuiWindowCtrl = this.win
    win.text = title
    win.position = `${Math.floor((ScreenWidth - WIN_W) / 2)},${Math.floor((ScreenHeight - WIN_H) / 2)}`
    win.show()
    win.bringtofront()
    // Modal: the arrow keys turn pages instead of walking the player.
    disabledefmovement()
    render()
}

function render() {
    const last = this.page >= this.pages.length - 1
    this.body.text = this.pages[this.page]
    this.pageLabel.text = `Page ${this.page + 1} of ${this.pages.length}`
    this.prevButton.visible = this.page > 0
    this.nextButton.text = last ? 'Close' : 'Next'
}

function turnPage(delta: number) {
    if (!this.open) return
    const page = Math.max(0, Math.min(this.pages.length - 1, this.page + delta))
    if (page === this.page) return
    this.page = page
    render()
}

function close() {
    if (!this.open) return
    this.open = false
    this.win.hide()
    enabledefmovement()
    // Show the next queued message, if any.
    const queued = this.queue.shift()
    if (queued) showMessage(queued[0], queued[1])
}

measuretext(text, { fontsize, width }) wraps text exactly as a label with that width and font size would. It returns the wrapped lines and the lineheight. Slicing lines into groups that fit the body height gives you pages. Joining a page with '\n' reproduces the same wrapping in the label.

showMessage is exported, which makes it a handler that other scripts can call:

  • another clientside weapon: findweapon('messagebox')?.trigger('showMessage', 'Title', 'Text'). WeaponHandle.trigger runs it immediately, and any controls it creates belong to the message box weapon;
  • a serverside script: triggerClient('weapon', 'messagebox', 'show', title, text), which arrives in onActionClientSide (see step 9).

While the box is open, disabledefmovement() keeps the arrow keys from walking the player, so the box behaves like a modal dialog. enabledefmovement() in close() gives movement back.

Step 8: keyboard ​

ts
// Keyboard: onKeyPressed hears keys while the game has the keyboard (not
// while the chat bar or a text field is focused), once per press.
export function onKeyPressed(key: string) {
    if (!this.open) return
    if (key === 'Escape') {
        close()
    } else if (key === 'Left') {
        turnPage(-1)
    } else if (key === 'Right' || key === 'Space' || key === 'Enter') {
        if (this.page < this.pages.length - 1) turnPage(1)
        else if (key !== 'Right') close()
    }
}

onKeyPressed vs .on('keydown')

GUI controls also support .on('keydown') / .on('keyup'), but those events are routed by keyboard focus: they fire only while that control, or a child of it, is focused. In practice that means a text field the player clicked into. A window or button doesn't hold focus for keys, so a message box that should react to Space as soon as it opens uses onKeyPressed. Tutorial 3 uses .on('keydown') on a quantity field, where focus is exactly what you want.

Step 9: entry points ​

ts
export function onCreated() {
    this.open = false
    this.queue = []
    this.pages = []
    this.page = 0
    build()

    // client.* flags persist in the account, so the welcome shows once.
    if (!client.welcomeSeen) {
        client.welcomeSeen = true
        showMessage('Welcome!',
            'Welcome to the server! This window is the messagebox weapon: it word-wraps '
            + 'long text and splits it into pages that fit.\n\n'
            + 'Turn pages with the Next and Back buttons, the mouse wheel over the window, '
            + 'or the keyboard: Space, Enter and the arrow keys. Escape closes the window.\n\n'
            + 'The health bar and gold counter in the top-right corner are the hud weapon. '
            + 'Press H to pick a fight and watch them change - if your health runs out, the '
            + 'server opens this window again to tell you what happened.')
    }
}

// The serverside entry point: triggerClient('weapon', 'messagebox', 'show', title, text).
export function onActionClientSide(action: string, ...params: any[]) {
    if (action === 'show')
        showMessage(String(params[0] ?? ''), String(params[1] ?? ''))
}

client flags are the one kind of flag the client can write. They sync to the server and persist in the account, which makes them good for per-player UI preferences such as "has seen the welcome message". Don't use them for anything the player shouldn't control: a player could set client.welcomeSeen themselves, and nothing bad happens.

The complete files ​

weapons/hud.ts
ts
// Serverside half of the `hud` weapon. The server owns the numbers the HUD
// shows: they live in clientr flags, which the player's client can read
// but never write, and every write here syncs to that client immediately.

const START_MAX_HP = 10
const START_GOLD = 25

// Seed the stats the first time an account logs in. clientr flags persist
// in the account file, so returning players keep their values.
export function onPlayerJoined(player: Player) {
    if (typeof player.clientr.maxhp !== 'number') player.clientr.maxhp = START_MAX_HP
    if (typeof player.clientr.hp !== 'number') player.clientr.hp = player.clientr.maxhp
    if (typeof player.clientr.gold !== 'number') player.clientr.gold = START_GOLD
}

// A stand-in for real gameplay: pressing H asks the server to "fight".
// The server decides the outcome and writes the flags; the HUD just reacts.
export function onActionServerSide(player: Player, action: string) {
    if (action !== 'demo') return

    const maxhp = Number(player.clientr.maxhp) || START_MAX_HP
    const hp = Number(player.clientr.hp) - (1 + Math.floor(Math.random() * 3))
    const loot = Math.floor(Math.random() * 10)
    player.clientr.gold = (Number(player.clientr.gold) || 0) + loot

    if (hp > 0) {
        player.clientr.hp = hp
        player.chat = loot > 0 ? `Found ${loot} gold!` : 'Ouch!'
        return
    }

    // Knocked out: refill and tell the player why, using the message box
    // weapon's clientside half. We are inside onActionServerSide, so
    // triggerClient knows which player to answer.
    player.clientr.hp = maxhp
    triggerClient('weapon', 'messagebox', 'show', 'Knocked out',
        'You collapse in a heap. A passing healer patches you up and sends you on '
        + 'your way.\n\nYour health has been fully restored, and you kept all of '
        + `your gold (${player.clientr.gold} in total). Try to be more careful next time!`)
}
weapons/hud.client.ts
ts
// Clientside half of the `hud` weapon: a health bar and a gold counter in
// the top-right corner, drawn with screen-space script images (findimg).
// The values come from clientr flags the serverside half writes.

const IMG_PANEL = 1
const IMG_BAR_BACK = 2
const IMG_BAR_FILL = 3
const IMG_HP_TEXT = 4
const IMG_COIN = 5
const IMG_GOLD_TEXT = 6

const PANEL_W = 180
const PANEL_H = 58
const MARGIN = 12
const BAR_W = PANEL_W - 20

// Places every HUD image relative to the top-right corner. Screen-mode
// images use pixel coordinates from the window's top-left.
function layout() {
    const left = ScreenWidth - PANEL_W - MARGIN
    const top = MARGIN

    const panel = findimg(IMG_PANEL)
    panel.screen = true
    panel.layer = 0
    panel.image = ''              // '' = a solid rectangle in the tint color
    panel.tint = '20,20,30'
    panel.alpha = 0.7
    panel.x = left
    panel.y = top
    panel.width = PANEL_W
    panel.height = PANEL_H

    const back = findimg(IMG_BAR_BACK)
    back.screen = true
    back.layer = 1
    back.tint = '70,20,20'
    back.x = left + 10
    back.y = top + 10
    back.width = BAR_W
    back.height = 14

    const fill = findimg(IMG_BAR_FILL)
    fill.screen = true
    fill.layer = 2
    fill.tint = '220,50,60'
    fill.x = left + 10
    fill.y = top + 10
    fill.height = 14

    const hpText = findimg(IMG_HP_TEXT)
    hpText.screen = true
    hpText.layer = 3
    hpText.style = 'bc'           // bold, centered on x
    hpText.fontsize = 12
    hpText.textshadow = true
    hpText.x = left + 10 + BAR_W / 2
    hpText.y = top + 9

    // A little diamond-shaped "coin": polygon vertices are screen pixels here.
    const coin = findimg(IMG_COIN)
    coin.screen = true
    coin.layer = 1
    coin.tint = '240,200,60'
    const cx = left + 18
    const cy = top + 41
    coin.polygon = [cx, cy - 7, cx + 6, cy, cx, cy + 7, cx - 6, cy]

    const goldText = findimg(IMG_GOLD_TEXT)
    goldText.screen = true
    goldText.layer = 1
    goldText.style = 'b'
    goldText.fontsize = 14
    goldText.textshadow = true
    goldText.tint = '255,230,140'
    goldText.x = left + 32
    goldText.y = top + 32

    this.laidOutFor = ScreenWidth
    this.shownHp = -1             // force a redraw of the values
    this.shownGold = -1
}

export function onCreated() {
    this.barHp = Number(clientr.hp) || 0
    layout()
}

export function onUpdate(dt: number) {
    // There is no anchoring API: re-run the layout when the window resizes.
    if (this.laidOutFor !== ScreenWidth)
        layout()

    const maxhp = Math.max(1, Number(clientr.maxhp) || 1)
    const hp = Math.max(0, Math.min(maxhp, Number(clientr.hp) || 0))
    const gold = Number(clientr.gold) || 0

    // The bar eases toward the real value so hits are easy to see.
    const diff = hp - this.barHp
    this.barHp = Math.abs(diff) < 0.01 ? hp : this.barHp + diff * Math.min(1, dt * 8)
    findimg(IMG_BAR_FILL).width = Math.round(BAR_W * this.barHp / maxhp)

    // Only touch the text when a value changed: every property write is a
    // (batched) host command, so skipping no-op writes keeps frames cheap.
    if (hp !== this.shownHp || maxhp !== this.shownMax) {
        this.shownHp = hp
        this.shownMax = maxhp
        findimg(IMG_HP_TEXT).text = `${hp} / ${maxhp}`
    }
    if (gold !== this.shownGold) {
        this.shownGold = gold
        findimg(IMG_GOLD_TEXT).text = `${gold} gold`
    }
}

// onKeyPressed fires once per press (no auto-repeat) and stays silent while
// the chat bar or a text field has focus - so typing "h" in chat is safe.
export function onKeyPressed(key: string) {
    if (key === 'H')
        triggerServer('weapon', this.name, 'demo')
}
weapons/messagebox.client.ts
ts
// The `messagebox` weapon: a paged message window any script can open.
//   - serverside scripts: triggerClient('weapon', 'messagebox', 'show', title, text)
//   - other clientside weapons: findweapon('messagebox')?.trigger('showMessage', title, text)
// Long text is word-wrapped with measuretext and split into pages that fit
// the window. Buttons, the mouse wheel and the keyboard all turn pages.

const WIN_W = 420
const WIN_H = 250
const PAD = 16
const TOP = 40                // window children start below the title bar
const FONT_SIZE = 16
const BUTTON_W = 90
const BUTTON_H = 30
const BODY_H = WIN_H - TOP - BUTTON_H - PAD * 2

// Builds the window once, hidden. Showing/hiding existing controls is
// cheaper than recreating them, and nothing flickers.
function build() {
    const win = new GuiWindowCtrl('MsgBox_Window')
    win.extent = `${WIN_W},${WIN_H}`
    win.visible = false

    const body = new GuiTextCtrl('MsgBox_Body')
    body.position = `${PAD},${TOP}`
    body.fontsize = FONT_SIZE
    win.addControl(body)

    const pageLabel = new GuiTextCtrl('MsgBox_Page')
    pageLabel.position = `${PAD},${WIN_H - PAD - BUTTON_H + 6}`
    pageLabel.fontsize = 12
    pageLabel.color = '180,180,190'
    win.addControl(pageLabel)

    const prev = new GuiButtonCtrl('MsgBox_Prev')
    prev.text = 'Back'
    prev.position = `${WIN_W - PAD - BUTTON_W * 2 - 8},${WIN_H - PAD - BUTTON_H}`
    prev.extent = `${BUTTON_W},${BUTTON_H}`
    win.addControl(prev)

    const next = new GuiButtonCtrl('MsgBox_Next')
    next.position = `${WIN_W - PAD - BUTTON_W},${WIN_H - PAD - BUTTON_H}`
    next.extent = `${BUTTON_W},${BUTTON_H}`
    win.addControl(next)

    this.win = win
    this.body = body
    this.pageLabel = pageLabel
    this.prevButton = prev
    this.nextButton = next
    listen(win, prev, next)
}

// .on() listeners are plain callbacks. Arrow functions inside a top-level
// function still see the weapon as `this`.
function listen(win: GuiWindowCtrl, prev: GuiButtonCtrl, next: GuiButtonCtrl) {
    prev.on('click', () => turnPage(-1))
    next.on('click', () => {
        if (this.page < this.pages.length - 1) turnPage(1)
        else close()
    })
    // Events bubble from child to parent: a wheel turn over the body label
    // reaches the window's listener too.
    win.on('mousewheel', e => turnPage(e.delta > 0 ? -1 : 1))
}

// Exported, so other weapons can call it through findweapon(...).trigger.
export function showMessage(title: string, text: string) {
    if (this.open) {
        // One window at a time: queue the rest.
        this.queue.push([title, text])
        return
    }

    // Wrap to the body width, then cut the wrapped lines into pages that
    // fit the body height. measuretext wraps exactly like a label would.
    const measured = measuretext(text, { fontsize: FONT_SIZE, width: WIN_W - PAD * 2 })
    const perPage = Math.max(1, Math.floor(BODY_H / measured.lineheight))
    const pages: string[] = []
    for (let i = 0; i < measured.lines.length; i += perPage)
        pages.push(measured.lines.slice(i, i + perPage).join('\n'))
    if (pages.length === 0) pages.push('')

    this.pages = pages
    this.page = 0
    this.open = true

    const win: GuiWindowCtrl = this.win
    win.text = title
    win.position = `${Math.floor((ScreenWidth - WIN_W) / 2)},${Math.floor((ScreenHeight - WIN_H) / 2)}`
    win.show()
    win.bringtofront()
    // Modal: the arrow keys turn pages instead of walking the player.
    disabledefmovement()
    render()
}

function render() {
    const last = this.page >= this.pages.length - 1
    this.body.text = this.pages[this.page]
    this.pageLabel.text = `Page ${this.page + 1} of ${this.pages.length}`
    this.prevButton.visible = this.page > 0
    this.nextButton.text = last ? 'Close' : 'Next'
}

function turnPage(delta: number) {
    if (!this.open) return
    const page = Math.max(0, Math.min(this.pages.length - 1, this.page + delta))
    if (page === this.page) return
    this.page = page
    render()
}

function close() {
    if (!this.open) return
    this.open = false
    this.win.hide()
    enabledefmovement()
    // Show the next queued message, if any.
    const queued = this.queue.shift()
    if (queued) showMessage(queued[0], queued[1])
}

// Keyboard: onKeyPressed hears keys while the game has the keyboard (not
// while the chat bar or a text field is focused), once per press.
export function onKeyPressed(key: string) {
    if (!this.open) return
    if (key === 'Escape') {
        close()
    } else if (key === 'Left') {
        turnPage(-1)
    } else if (key === 'Right' || key === 'Space' || key === 'Enter') {
        if (this.page < this.pages.length - 1) turnPage(1)
        else if (key !== 'Right') close()
    }
}

export function onCreated() {
    this.open = false
    this.queue = []
    this.pages = []
    this.page = 0
    build()

    // client.* flags persist in the account, so the welcome shows once.
    if (!client.welcomeSeen) {
        client.welcomeSeen = true
        showMessage('Welcome!',
            'Welcome to the server! This window is the messagebox weapon: it word-wraps '
            + 'long text and splits it into pages that fit.\n\n'
            + 'Turn pages with the Next and Back buttons, the mouse wheel over the window, '
            + 'or the keyboard: Space, Enter and the arrow keys. Escape closes the window.\n\n'
            + 'The health bar and gold counter in the top-right corner are the hud weapon. '
            + 'Press H to pick a fight and watch them change - if your health runs out, the '
            + 'server opens this window again to tell you what happened.')
    }
}

// The serverside entry point: triggerClient('weapon', 'messagebox', 'show', title, text).
export function onActionClientSide(action: string, ...params: any[]) {
    if (action === 'show')
        showMessage(String(params[0] ?? ''), String(params[1] ?? ''))
}

Try it ​

  1. Create hud (both halves) and messagebox in GRC's Weapons editor and save. Add "hud" and "messagebox" to startWeapons in Server Options, click Apply, then log in again (the welcome message is shown on login).
  2. The welcome message opens. Page through it with Space, the Next button and the mouse wheel, and go back with Left or Back. Close it with Escape. Log out and back in: it doesn't come back.
  3. Press H a few times. The bar shrinks smoothly, your gold goes up, and your character comments in a chat bubble.
  4. Keep pressing until your health runs out. The server refills it and opens a message box explaining what happened.
  5. Resize the game window: the HUD stays in the top-right corner.

Next steps ​

  • More stats: add a mana bar. Write clientr.mp on the server and add three more images. Consider a helper bar(idBase, x, y, value, max, color).
  • Low health warning: tint the bar and pulse its alpha when hp / maxhp < 0.25.
  • Choice dialogs: give showMessage an optional list of buttons and report the clicked one back to the server with triggerServer('weapon', …), for quests and yes/no prompts.
  • Typing effect: reveal each page one character at a time with setInterval. Updating an existing label's text from the timer is fine. Don't create controls there, though: controls created in timer callbacks aren't cleaned up when the weapon reloads.
  • Continue with Shop & inventory, which spends the gold shown here.