< Back 

OpenRAHS

2026-10-04

An open-source Luau library for the row of button hints in the corner of a Roblox game. It picks the glyph for whatever the player is holding, follows keyboard layouts and Input Action rebinds, never touches input, and draws through a frontend you can replace one event at a time.

LuauRobloxUIInput
Go to quick view

Pointing at a button you don’t own.

Abstract. OpenRAHS is the open-source version of the contextual hint row I built for Rod and Reef: the small stack in the corner of the screen that says [E] Talk or [LMB] Cast and changes with what the player is doing. Drawing the row is the easy part. The glyph depends on the device in hand, on the brand of the pad, on the keyboard layout and, with Roblox’s Input Action System, on whatever key the player rebound the action to, and every hint is turned on and off by a different system that knows nothing about the others. This article describes how the library splits that up. Hints are global objects addressed by id, and whether one is on screen is a conjunction of four conditions, so no system needs to know the boot order or anybody else’s state. Input is observed and never bound. Glyphs are resolved when they’re read, through a table of names written by hand and a separate table of uploaded ids. Drawing goes through a contract of thirteen events, with a default frontend that also covers every event a custom frontend leaves out. The library is about 2,700 lines of Luau, plus three Python scripts that upload its 640 CC0 icons to Roblox.

1. The problem

A hint row tells the player which button does the thing in front of them. On a keyboard casting is a left click, on a pad it’s the right trigger and on a phone it’s a tap, so a single hint already has three faces. Pads add a second axis: Roblox calls the bottom face button ButtonA, which is a green A on an Xbox controller and a blue cross on a DualSense. Keyboards add a third, because Enum.KeyCode names positions on a QWERTY keyboard, and a French player pressing the key labelled A sends Enum.KeyCode.Q.

The other half of the problem is who turns hints on. In Rod and Reef several systems write to the row. The fishing minigame, for one, uses it to tell the player when to click, through a function that answers whether a click right now would hit. None of those systems should have to know which hints the others are showing, in what order the scripts loaded, or whether a cutscene is hiding the row.

That leaves four requirements. The glyph has to be right for the device, the brand, the layout and the current binding. Any system has to be able to show or hide any hint at any moment without coordinating with the rest. The row can’t interfere with the input it describes, since every key it names is bound by some other system. And the look has to be replaceable, because each game has its own interface, while still working before anyone has uploaded a single icon.

2. Hints as data

2.1 Addressed by id

OpenRAHS:Hint(id) returns the hint with that id and creates it the first time it’s asked for. It never returns nil and never throws, and hints are never destroyed. The script that declares what a hint says and the script that decides when it shows can therefore run in any order, from any module, with no registration step:

OpenRAHS:Hint("Fishing_Cast")
    :SetInput({ Keyboard = Enum.UserInputType.MouseButton1, Gamepad = Enum.KeyCode.ButtonR2 })
    :SetLabel("Cast")

-- elsewhere, earlier or later
OpenRAHS:Hint("Fishing_Cast"):Show()

The price is that a misspelt id creates a new hint instead of an error. A hint that was never declared has no inputs, and a hint with no inputs never draws, so the typo shows up as a hint that doesn’t appear rather than as a wrong one.

2.2 What “on screen” means

For a hint h, let rh be whether the game asked for it (Set(true)), th whether it’s retired (section 2.3), ρh whether it’s in the short window between being retired and leaving, Ih(d) the inputs it declares for the device d in hand, and S the set of reasons the row is suppressed. The hint is drawn when

drawn(h)⟺rh∧(¬th∨ρh)∧|Ih(d)|>0∧S=⌀

Each term lives in a different place. The first two make up the hint’s Visible field, which is what gameplay code controls. The third is IsRenderable(), and it’s how a keyboard-only hint stays off a phone with no platform check anywhere: a hint declared without a Touch input simply has nothing to say there. The fourth belongs to the row.

Suppression is a set of named reasons rather than a boolean. With a boolean, a cutscene that ends turns the row back on over a menu that’s still open. A counter fixes that, but it drifts for good the first time something releases twice. With a set, SetSuppressed(true, "Cutscene") is idempotent, each system releases only its own reason, and the row comes back when S is empty.

2.3 Hints that teach once

A hint marked Once retires the first time the player presses its key, and a retired hint stays off for the rest of the session however often the game asks for it, which is what a tutorial line needs. Retiring immediately would cut the flourish that confirms the press, so the hint retires with a 0.32 s delay. That’s the ρh term above: during that window the hint counts as retired but still draws. Retirement lives in memory only, so a game that wants it to survive a rejoin saves the fact itself and calls Retire() on the next join.

2.4 A stable order

Visible hints are sorted by a number, Order, with ties broken by id:

h1≺h2⟺o1<o2∨(o1=o2∧id1<id2)

The tie-breaker is there because hints live in a dictionary and Luau doesn’t define the iteration order of one. Without it, two hints with the same order could swap places between two syncs and the row would twitch. Hints with no order get 100, so anything ordered on purpose sorts ahead of them.

2.5 Conditions instead of calls

Most hints depend on a condition, and toggling them from every place that affects it spreads one fact over several files. ShowWhen(condition, triggers) turns the hint into a function of state: the condition is checked right away and again whenever one of the triggers fires. In the library’s playground, the reload hint is

hint:ShowWhen(function()
    return Holding("Blaster") and Gameplay.Ammo < Gameplay.Magazine and not Gameplay.Reloading
end, { character.ChildAdded, character.ChildRemoved, Gameplay.Changed })

so it appears after the first shot and goes away during the reload and once the magazine is full. Each hint holds at most one condition and a second call replaces the first, which makes re-binding to a respawned character a single line with nothing to clean up. Without triggers the condition runs every Heartbeat, which is fine for a cheap read. A condition that throws is reported once and leaves the hint as it was.

3. Devices

3.1 Which device

The device is the one the player used last, not what the hardware supports. A laptop with a touchscreen and a pad plugged in is whichever of the three was touched most recently. There are three sources, in order. A game that already decides whether a player is on touch for its own HUD can hand that function over, so the row can never disagree with the rest of the screen. Without one, OpenRAHS reads UserInputService.PreferredInput, the property Roblox’s own InputActionLabel follows [6]. On engine builds without that property it falls back to the last input type.

That fallback needs an opening guess before the player touches anything, and the guess only calls a device a computer if it reports both a keyboard and a mouse, because some phones report a phantom KeyboardEnabled while almost nothing reports MouseEnabled without a mouse. Consoles are pinned to the gamepad, since their on-screen keyboard reports keyboard input and would otherwise swap every glyph to keys while the player types a name.

3.2 Which pad

UserInputService:GetStringForKeyCode(Enum.KeyCode.ButtonA) returns "ButtonCross" when the last pad connected was a PlayStation one, and "ButtonA" for an Xbox pad, an unknown pad or no pad at all. The art set, which the code calls the family, is the device plus that brand. The PlayStation table isn’t a letter-for-letter translation of the Xbox one, because Roblox names pad buttons by position: ButtonA is the bottom button, which is the cross, and ButtonX is the left one, which is the square.

3.3 Nothing is cached

A hint keeps its inputs as declared and never stores the glyph it last drew. GetInputs, GetGlyphs and IsPressed resolve against the device and the held keys when they’re called, and a device change fires a single Refreshed signal after which the frontend reads everything again. That’s why a hint can’t keep Xbox art after the pad is put down, or a key lit after it was released in another window.

4. From an input to a glyph

4.1 Two tables

An input goes through two hops before it becomes an image. Bindings maps it to an atlas name, such as keyboard_e, and is written by hand. Assets maps the name to an uploaded id and is generated. Keeping them apart has two consequences. Uploading the art again regenerates Assets and never touches the hand-written half. And the pressed state becomes an operation on names: Kenney’s pack [5] ships most keys twice, keyboard_e filled and keyboard_e_outline hollow, so the glyph draws the hollow one at rest and the filled one while the key is held, falling back to the single image when there’s no pair.

The ids belong to whoever uploaded the icons, so the package ships with an empty Assets table. Until a game has ids, every glyph is drawn as a text keycap, which means the library works the moment it’s installed.

4.2 Keyboard layouts

Letters are drawn from GetStringForKeyCode, which knows the player’s layout. A hint bound to Enum.KeyCode.Q therefore shows “A” to an AZERTY player, which is the key they actually have to press, and uses keyboard_a as its icon. It only applies to letters, and a game that teaches positions instead of letters can turn it off.

4.3 Fallbacks

A name is looked up in the current family first and then in the others, so a hint that names ButtonA without a per-device table still draws a pad button for a keyboard player instead of nothing. When there’s no mapping at all, the glyph gets a neutral button. Names written as strings are returned untouched even if the atlas has never heard of them, so a typo shows up as a missing icon instead of being swapped for something plausible.

Captions go through their own chain: a per-input override, then the library’s tables, then the engine’s abbreviation and finally the enum name. Every symbol in those tables was checked against Builder Sans, because a few obvious choices render as an empty box. The PlayStation cross is × (U+00D7) because the multiplication X at U+2715 is one of the missing ones.

4.4 The mouse exists twice

Roblox spells a left click both as Enum.KeyCode.MouseLeftButton and as Enum.UserInputType.MouseButton1, but InputBegan only ever reports the second. A hint declared the first way would draw correctly and never light up, which is the kind of bug that survives a playtest. An alias table folds every legacy mouse KeyCode onto its UserInputType before lookup and before tracking presses.

5. Watching input without owning it

OpenRAHS listens to InputBegan and InputEnded, which report input and never consume it, and it never binds anything through ContextActionService or the Input Action System. Every key a hint describes is bound by some other system, and an observer [1] can’t steal that key or fight over it. A wrong hint can confuse a player but can’t change what the game does.

It ignores gameProcessedEvent on purpose, because a click on a button still obeys a “click to cast” hint. The exception is a focused TextBox: typing “m” in the chat isn’t pressing the menu key, and counting it would retire a Once hint for the session.

Held keys are counted instead of stored as booleans,

ck←ck+1 on press,ck←ck−1 on release,held(k)⟺ck>0

and the reason is touch. Two fingers down are two InputBegan on Touch and lifting one is a single InputEnded, so a boolean would turn the glyph off while a finger is still on the glass. Losing window focus or disconnecting a pad releases everything at once, since a key held during an alt-tab never delivers its InputEnded.

Pressing a key that a visible hint names fires Fulfilled with the hint and the position of that key in it. The frontend uses the position to play the flourish on the glyph that was pressed, so on a steering hint with A and D the rings only come out of the D when you press D. An earlier version rang every glyph of the hint, and the fix was to carry the index through the event instead of guessing it in the frontend.

6. The Input Action System

Games built on Roblox’s Input Action System [6] already declare each key once, on an InputBinding, and SetInputAction(action) makes a hint follow it. The obvious signal for this, InputAction.InputBindingsChanged, turned out to be RobloxScript security: a game script that connects to it gets “lacking capability RobloxScript”. So bindings are read as the action’s children and followed through ChildAdded, ChildRemoved and each binding’s property signals, which is enough for a rebind menu that writes binding.KeyCode to redraw the hint on the same frame.

A binding’s keys come out in the order a player reads them: modifiers before the key, and a directional binding as Up, Left, Down, Right, which is how W, A, S and D sit on the keyboard. The device a binding belongs to is read from its main key, where Button*, DPad* and Thumbstick* belong to the pad. If K, G and T are the keys found for keyboard, gamepad and touch, the hint gets

specn=(Kn, Gn, Tn),1≤n≤min⁡(max⁡(|K|,|G|,|T|), 4)

so position n is glyph n on every device. A keyboard binding of Shift + E next to a pad binding of ButtonX becomes {(Shift,ButtonX), (E,⌀)}: two glyphs on a keyboard and one on a pad. Bindings are sorted by name first, since GetChildren doesn’t guarantee an order and which binding counts as the keyboard one shouldn’t change between two calls. A UIButton binding draws nothing on purpose, because the button is already on the player’s screen. The cap of four is WASD; past that the label sits too far from the keys to read as one hint.

7. The frontend contract

7.1 When versus how

The core never builds an Instance and the frontend never decides visibility. A frontend is a table of up to 13 handlers (Mount, Unmount, Build, Appear, Update, Press, Release, Fulfill, Reorder, Disappear, Revive, Step and Destroy), and a stage calls them at the right moment. The stage keeps one view per hint id for as long as the hint is on screen, plus however long its exit animation takes. A hint shown again in the middle of its exit gets Revive on the same view instead of a second row.

Every handler is optional, and the ones a frontend leaves out fall back to the default frontend’s, so restyling one moment of the animation is one function. Calling Start again swaps the frontend live and the hints don’t notice, because everything the stage knows comes from three signals and one read of the visible list.

7.2 Exits

Disappear returns how long the exit takes and the stage destroys the row then. A frontend that animates from Step can return nil instead and have Step return false when it’s done. If it never does, the row is destroyed after five seconds anyway. That limit is a safety net against a Step that forgets to return false, which would otherwise keep every dismissed row alive for the whole session.

7.3 User code inside the library

A frontend is user code running inside the library’s events, so every call goes through xpcall. An error in one handler costs that one call. It’s reported once with a traceback and later errors from the same handler are silenced, so a broken Step doesn’t print sixty times a second. Unprotected, an error in Appear would stop the sync halfway and leave every later row unbuilt. Configuration gets the same treatment: an override key that isn’t an event, like Appaer, is reported instead of being ignored while the default keeps running, and a theme token with the wrong type is reported and skipped.

8. The default frontend

8.1 Widths nobody animates

Every frame in the default row sizes itself to its content with AutomaticSize, and in Studio that turned out to count every descendant, scaled ones included. A key squashed with a UIScale on press narrowed its own slot, and a ring growing inside a glyph widened the row, so the label next to them twitched sideways on every press. The fix was to keep anything that animates away from widths: a pressed key moves 2 pixels down instead of shrinking, and the rings are drawn on a separate layer, positioned from the glyph’s AbsolutePosition.

A related surprise was that a UISizeConstraint on an auto-sized TextLabel applies to the area inside its padding. A minimum width of 32 with 9 pixels of padding on each side drew a 50-pixel key, which is why the minimum is computed as KeySize - 2xKeyPadding.

8.2 Fading with resting values

Setting every transparency in the row to the same value would make a drop shadow that rests at 0.5 jump to fully opaque halfway through a fade. Each part registers a channel with its resting value vrest, and fading the row to an opacity α sets

v(α)=1−(1−vrest)α

which is vrest at α=1 and 1 at α=0. A restyled Build that adds a background registers one more channel, and it fades with the rest.

8.3 Leaving rows keep their place

Rows sit in a UIListLayout sorted by LayoutOrder. A row on its way out has to keep its slot while the others move, but a new row can arrive at the same index, and the layout breaks ties by insertion order, which made new rows land one slot off and then jump. Staying rows get even numbers and leaving rows odd ones,

LayoutOrder(v)=2iv+ℓv,ℓv∈{0,1}

where iv is the row’s index and ℓv is 1 while it leaves. An odd number never equals an even one, so there are no ties left to break.

8.4 Scale

The stack is designed at a reference height Href=720. It shrinks on shorter viewports, down to a floor m=0.6 so the row on a small phone stays readable, and never grows past the theme’s own scale:

s=Scale⋅min⁡(1, max⁡(m, HHref))

The connection to the viewport is rebound whenever CurrentCamera changes, because one left on the old camera stops following the window after a respawn.

8.5 Small things players notice

Images download the first time they’re used, which for the filled variant of a glyph is the player’s first press, exactly when they’re looking at it. The stage preloads both variants of every glyph when its row appears. The default frontend also follows the player’s Reduced Motion setting [4]: rows still fade and change colour, but nothing pops, slides, sinks or rings. Labels aren’t translated automatically unless a game turns it on, because short interface words translated without context tend to come out wrong (a Spanish client turned “Sprint” into “Esprintar”). Keycaps are never translated, since “Shift” is what’s printed on the key.

9. Animating from a clock

The default frontend animates with tweens. The repository also includes a complete frontend written against the public API only, a dark pill per hint, which keeps a few clocks per row and redraws the row from them every frame in Step. Interruptions are where that pays off: a press during the entrance is a second clock running next to the first, not two tweens fighting over the same property.

For that style the library ships easing helpers, among them the CSS cubic-bezier curve [2], so a curve designed in a browser can be ported as numbers. With P0=(0,0) and P3=(1,1) fixed, each coordinate is a cubic Bézier in a parameter t,

x(t)=3(1−t)2tx1+3(1−t)t2x2+t3

and likewise for y. The curve is defined as y against x, so evaluating it means solving x(t)=x for t and then computing y(t). The solver starts Newton’s method [3] at t0=x, which is already close unless the control points are extreme:

tk+1=tk−x(tk)−xx′(tk)

with

x′(t)=3(1−t)2x1+6(1−t)t(x2−x1)+3t2(1−x2)

It returns as soon as the error drops below 10−7. If eight steps aren’t enough, or the slope falls under 10−6 and a Newton step would blow up, it falls back to bisection. CSS requires x1,x2∈[0,1], and that makes x(t) non-decreasing: the coefficients of x′ in Bernstein form are x1, x2−x1 and 1−x2, and when the middle one is negative, (x1−x2)2≤x1(1−x2) keeps the quadratic from going below zero. On a monotonic function bisection can’t miss the root, and 24 halvings leave t within 2−24≈6×10−8 of it. Control points that overshoot on y (a pop with y1>1) are fine, since only x has to be monotonic.

10. The icons

The repository includes 640 icons from Kenney’s Input Prompts pack [5], released under CC0, in six families: keyboard and mouse (257), PlayStation (129), Xbox (97), flairs (93), generic buttons (36) and touch gestures (28). Three Python scripts, about 600 lines using only the standard library, turn them into ids a game owns. upload.py sends each PNG through the Open Cloud Assets API, four at a time, and writes every result to a log the moment it arrives, so a run cut halfway resumes without paying for anything twice. A 429 is retried with a growing wait, and a 401 or 403 stops the whole batch, because every later upload would fail the same way. generate.py writes the Luau table from the log, and verify.py checks that every name the bindings can ask for has both a PNG and an id.

The uploads are Images and not Decals, which I learned the hard way. Both kinds upload fine and come back approved, but a Decal’s id belongs to a wrapper around the image, and ImageLabel.Image = "rbxassetid://<decal id>" never loads: IsLoaded stays false with no error or warning anywhere. An Image upload returns the id an ImageLabel understands.

11. Releasing it

The released version depends on three common libraries, Janitor, Promise and GoodSignal, required as siblings of the package because that’s where both Wally and a hand-made Packages folder put them. One packaging detail almost slipped through: Wally’s include list restricts nothing on its own, and without exclude = ["**"] next to it the package would have shipped all 640 icons. The API is documented with Moonwave from comments in the source, and the repository includes a playground place built entirely at runtime, with a sword, a blaster, a drivable car and an NPC, where every hint comes and goes with what the player is doing.

12. Limitations

  • There’s no Command key on a Mac. The only call that could detect macOS, UserInputService:GetPlatform, throws from game code, so every Super key draws as the Windows key. A hint that knows it’s on a Mac can name keyboard_command directly.
  • Brand detection knows Xbox and PlayStation and nothing else. Any other pad draws with Xbox art, and with two pads connected it follows the last one.
  • Layout awareness only covers letters. Punctuation keys are still drawn by their QWERTY position.
  • A misspelt id creates a hint that never draws instead of an error (section 2.1).
  • Retirement doesn’t survive a rejoin without the game’s help.
  • A hint draws at most four inputs.
  • Handlers must not yield, Mount aside, because they run inside the event that caused them.

13. What I took away

The decision I’d repeat is making the library an observer. It never binds or sinks a key, so there’s no initialization order in which it can break a control, and no other system has to know it exists. A lot became simpler once that was settled. Fulfilled could be a plain observation, and a hint never needs to know whether the action it describes actually happened.

The second is resolving at read time instead of storing results. A hint keeps what was declared and computes the glyph when someone asks, which is the same rule I follow in Rod and Reef: derive instead of maintain. A device switch, a rebind and a new table of ids all end up as one redraw instead of three caches to invalidate.

References

  1. R. Nystrom. Game Programming Patterns. Genever Benning, 2014.
  2. W3C. CSS Easing Functions Level 1. W3C Candidate Recommendation.
  3. R. L. Burden and J. D. Faires. Numerical Analysis, 9th ed. Brooks/Cole, 2011.
  4. W3C. Web Content Accessibility Guidelines (WCAG) 2.1, Success Criterion 2.3.3, Animation from Interactions. 2018.
  5. Kenney. Input Prompts. Asset pack, CC0. kenney.nl.
  6. Roblox. Creator Documentation: UserInputService and the Input Action System. create.roblox.com/docs.

This text by Mastedore. All rights reserved.