# controlsKit
Ready-made control schemes for **keyboard + mouse, gamepad and touch** in one package. Pick a genre preset with one line and your game is playable on a phone, with a controller and on a keyboard at once. Swap schemes, sticks, button layouts, looks and button-prompt sets live, and the game's own code never changes, because every scheme speaks the same named actions.
It builds on the engine's builtin input stack (Zin, `@builtin::zinput`) for devices, touch contacts and pad families. It adds what Zin doesn't have: genre schemes, on-screen sticks and buttons, gestures, prompt glyphs, looks, phone-safe layout and a rebinding screen.
## Install and use
Install the package into your world. It is published on ZeroMind as `controlsKit.package`, guid `889ac3ad-87d6-427a-8bc4-3e955943fa6c`, with no dependencies:
```lua
tools.use("zeromind", "installAsset", "889ac3ad-87d6-427a-8bc4-3e955943fa6c") -- from Luau
```
or `zero zeromind installAsset 889ac3ad-87d6-427a-8bc4-3e955943fa6c` in the engine shell, or search ZeroMind for "controlsKit" and install it. It lands at `/source/controlsKit.package`. While the files arrive, the editor may warn once that `ControlsMenu`, `ControlsMover` and `ControlsGallery` are "waiting for content it requires": that is install order, it clears by itself as soon as the modules land, and there is nothing to do. Then, from any script or component:
```lua
local Controls = require("controlsKit.controls")
Controls.use("twinStick") -- keyboard+mouse, pad and touch, all live
-- every frame
local move = Controls.value("move") -- { x, y }, x right / y up, length <= 1
local aim = Controls.value("aim")
if Controls.held("fire") then shoot(aim) end
-- or by event
Controls.on("dash", function() dash() end) -- edge "pressed" (default)
Controls.on("pause", function() togglePause() end)
Controls.use("platformer") -- one call, mid-game: same action names
```
No code at all: add **`controlsKit.ControlsMenu`** to any entity and set its `scheme` field. Add **`controlsKit.ControlsMover`** to a character's body (next to the builtin `CharacterController`) and it walks, sprints, jumps or drives from whatever scheme is active.
Touch controls appear by themselves the moment the player touches the screen, and hide again when they pick up a keyboard or pad (`Controls.setVisibility("auto" | "always" | "never")`).
## Schemes
`Controls.schemes()` lists them; `Controls.describe(name)` returns `{ name, label, description, actions = {{ name, kind, label }} }`.
| Scheme | Actions | Keyboard + mouse | Pad (Xbox names) | Touch (default) |
|---|---|---|---|---|
| `platformer` | move, jump, dash, crouch, interact, pause | WASD/arrows, Space/K jump, Shift/J dash, Ctrl/C crouch, E/L interact | left stick, A jump, X/RB dash, B crouch, Y interact | 8-way d-pad left, diamond (Jump/Dash/Duck/Use) right; swipe up = jump |
| `twinStick` | move, aim, fire, dash, bomb, pause | WASD move, mouse (or arrows) aims from screen centre, LMB/Space fire, Shift/RMB dash, Q/E bomb | left stick moves, right stick aims and pushing it fires, RT/RB fire, LT/A dash, LB/B bomb | floating move stick left, fixed aim stick right (aiming fires), Dash + Bomb buttons; double tap = bomb |
| `fps` | move, look, fire, aimDown, jump, crouch, sprint, reload, interact, pause | WASD, mouse look (click locks the pointer), LMB fire, RMB aim, Space jump, C/Ctrl crouch, Shift sprint, R reload, E/F interact | twin sticks, RT fire, LT aim, A jump, B crouch, L3 sprint, X reload, Y interact | follow stick left, drag the right half to look, diamond (Fire/Jump/Reload/Crouch) + Sprint, Aim, Use buttons |
| `racing` | steer, throttle, brake, boost, handbrake, camera, pause | A/D or arrows steer, W throttle, S brake, Shift boost, Space handbrake, C camera | left stick steers, analogue RT throttle / LT brake, Y/LB boost, B/RB handbrake, View camera | arcade stick steers, Gas/Brake pedal pair, Boost + Handbrake buttons, camera in the corner; swipe up = boost |
| `rts` | pan, zoom, select, command, attack, stop, build, rally, pause | WASD/arrows pan, Q/E or −/= zoom, LMB select, RMB command, F attack, X stop, B build, R rally | left stick pans, LT/RT zoom, A select, B command, d-pad orders | drag anywhere pans, pinch zooms, tap selects, long press commands, radial order wheel (Build/Attack/Stop/Rally) |
| `rhythm` | lane1–lane4, flick, pause | D F J K lanes, Space flick | d-pad left, d-pad down, A, B lanes; bumpers flick | two pairs of big lane pads, one per thumb; swipe any way = flick |
| `card` | navigate, select, play, cancel, inspect, prev, next, endTurn, pause | WASD/arrows browse, LMB/Enter select, Space play, Backspace cancel, I/RMB inspect, Q/E prev/next, Tab end turn | left stick browses, A select, Y play, B cancel, X inspect, LB/RB prev/next, View end turn | tap selects, swipe left/right = next/prev, swipe up = play, swipe down = cancel, long press = inspect, Play + End-turn buttons |
The exact defaults are in the rebinding screen and in `Controls.bindingSlots()`. Every scheme has `pause` (Esc/P, Menu, and a corner button on touch).
**Action kinds:** `button` reads 0/1 with `held` / `pressed` / `released` / `charge`; `vec2` reads `{ x, y }`; `axis1` reads −1..1 (0..1 for throttle-like actions); `delta2` reads `{ x, y }` screen points moved this frame (fps `look`).
**Shared names.** `move`, `jump`, `dash`, `fire`, `interact`, `crouch`, `select` and `pause` mean the same thing in every scheme that has them. A game written against `move` / `jump` / `pause` keeps working when the scheme changes.
## Styles
`Controls.styles()` returns all of these as lists. Each is one call and applies live.
| Category | Values | Set with |
|---|---|---|
| On-screen sticks | `fixed`, `floating` (appears where your thumb lands), `follow` (base trails your thumb), `dpad8` (8-way d-pad), `arcade` (chunky gated ball-top), `drag` (invisible drag area) | `Controls.setStick(variant, side?)` (`"left"`, `"right"`, `"both"`) |
| Touch button layouts | `single` (one big button), `pair`, `diamond` (four-button), `radial` (action wheel: press and slide), `charge` (hold-to-charge) | `Controls.setButtons(layout)` |
| Gestures | `tap`, `doubleTap`, `longPress`, `swipeUp`, `swipeDown`, `swipeLeft`, `swipeRight`, `pinch`, `tilt` (see below) | `Controls.onGesture(kind, fn)`; `"*"` hears all |
| Looks (skins) | `minimal`, `scifi`, `cartoon`, `pixel`, `neon` | `Controls.setSkin(name)`; your own with `require("controlsKit.skins").register(name, t)` |
| Prompt families | `keyboard`, `xbox`, `playstation`, `switch`, `generic` | auto from the device in hand; force with `Controls.setPromptFamily(f)` (`nil` = auto) |
| Prompt styles | `minimal`, `pixel` | `Controls.setPromptStyle(s)` |
Other settings: `Controls.setScale(s)` scales every touch control (it never shrinks a target below 44 pt); `Controls.setVisibility(mode)`; `Controls.setAimOrigin(x, y)` sets the screen point mouse aim is measured from.
**Hold-to-charge:** `Controls.charge("jump")` is 0..1 while held; `Controls.lastCharge("jump")` is the charge at release. This works on keyboard and pad too.
**Gestures** fire after the finger lifts. A double tap also fires the single `tap`. `pinch` drives an `axis1` action (rts `zoom`) as a per-frame rate.
**Tilt is not supported.** The engine's input snapshot carries no accelerometer, gyroscope or device-orientation data. `Controls.capabilities().tilt` is `false`, `tiltReason` says why, and `onGesture("tilt")` warns once and never fires. Nothing fakes it.
## Button prompts
```lua
local p = Controls.prompt("jump") -- { codes, family, style, label } for the device in hand
local node = Controls.promptWidget("jump", 28) -- a canvas node to drop into a HUD
```
The `ControlsMenu` hint strip and the rebinding screen use the same glyphs. Pads are matched by family: PlayStation pads get Cross/Circle, Nintendo pads get Switch labels (B/A swapped by position), anything else gets Xbox. The `generic` set is position-dots for any pad; choose it with `setPromptFamily("generic")`.
## Rebinding
The **Controls** button from `ControlsMenu`, or `require("controlsKit.rebindScreen").open()`, opens the rebinding screen. It lists each action with two key slots and a pad slot, drawn as prompts. Click a slot, press a key or button (Esc cancels, right-click clears). Clashes are outlined in red with a warning line naming both actions, and **Reset to defaults** restores the scheme. From code:
```lua
Controls.rebind("jump", "press", "keys", 1, "KeyG") -- action, part, "keys" | "pad", slot, code
for _, c in Controls.conflicts() do print(c.device, c.code, #c.uses) end
Controls.resetBindings()
local saved = Controls.exportBindings() -- a table to keep in your save data
Controls.importBindings(saved)
```
Parts: buttons use `"press"`, `vec2` uses `"up"` / `"down"` / `"left"` / `"right"`, `axis1` uses `"neg"` / `"pos"`. Custom bindings are kept per scheme across switches.
## Phones
Touch controls are laid out in points from the screen's real size and safe area, and re-laid out on rotation:
- every target is at least 44 pt;
- sticks sit where the thumb rests in the lower corners;
- nothing overlaps;
- everything stays inside the safe area and clear of notch and punch-hole cut-outs.
`require("controlsKit.layout").report(placed)` returns `{ overlaps, offscreen, unsafe, tooSmall }` for any layout; the test suite checks all seven schemes on four phone shapes. `ControlsMenu` shrinks to just the Controls button and the scheme chip on a phone held upright.
## Multiplayer
Each peer runs its own copy of the kit, so `Controls.*` is always the local player. `ControlsMover` only drives a body this peer owns (`isLocal()`), so one player's input never moves another player's character. Both components are safe on a player prototype. For several players on one machine:
```lua
local p2 = Controls.player(2, { keyboard = false, mouse = false, pad = 2, touch = false })
p2:use("racing")
local steer = p2:value("steer") -- same API as Controls.*, its own bindings and scheme
```
Set `ControlsMover.player = 2` on the second body.
## Components
| Component | Fields | What it does |
|---|---|---|
| `ControlsMenu` | `scheme`, `skin`, `showMenuButton` (true), `showHints` (true), `showSwitcher` (false), `openOnPause` (false) | Switches the scheme on, then shows the Controls button, a prompt hint strip and, optionally, chips cycling scheme / stick / buttons / look / icons |
| `ControlsMover` | `player` (1), `speed` (5), `sprintMultiplier` (1.7), `turnRate` (2.6) | Walks a `CharacterController` body from `move` / `navigate` / `pan`, `sprint` / `boost` and `jump`; drives like a car under `racing` |
| `ControlsGallery` | `scheme` ("platformer") | The whole kit on one screen, everything reacting together |
## API at a glance
`Controls.` … `use(name, opts?)`, `scheme()`, `schemes()`, `describe(name)`, `styles()`, `capabilities()`, `value`, `held`, `pressed`, `released`, `charge`, `lastCharge`, `point`, `on(action, fn, edge?)`, `off(handle)`, `onGesture(kind, fn)`, `drive(action, value)` (scripted / AI input; `nil` clears), `device()`, `setStick`, `setButtons`, `setSkin`, `setVisibility`, `setScale`, `setAimOrigin`, `setPromptStyle`, `setPromptFamily`, `prompt`, `promptWidget`, `bindingSlots`, `rebind`, `conflicts`, `resetBindings`, `exportBindings`, `importBindings`, `captureNext`, `cancelCapture`, `isCapturing`, `player(n, filter?)`, `stop()`, `VERSION`, `TILT_REASON`.
## What's inside
`controls` (public API), `schemes` (presets and catalogue), `overlay` + `widgets` + `layout` (touch controls and phone layout), `skins` (looks), `prompts` (glyphs), `rebindScreen`, `gallery`, `kit.inputMap`, the three components and the `controlsKitTests` suite (`tools.use("tests", "run", { suite = "controlsKitTests" })`, edit mode).
# menusKit
Every menu a game needs, as one styled screen stack: **title, pause, settings, game over, victory, loading and credits**. One call puts a finished screen up, and the game listens for what the player picked. There are **8 looks** (sci-fi, fantasy parchment, cartoon, pixel retro, minimal, military, horror, cozy) and **4 layouts** (centred list, side rail, card carousel, radial). One `setTheme` or `setLayout` call restyles every screen at once. It works with **keyboard, gamepad and touch**, and ships in **English, Brazilian Portuguese and Spanish**.
**One-step install:** install menusKit from ZeroMind and you are done. It brings controlsKit (button prompts, input reading and rebinding) along by itself and spawns its own menu host, so there is nothing to place in a scene. See *Install and use*.
It is built for multiplayer worlds:
- Pause is **per player**. Escape, Start or the on-screen pause button pauses only that player's game and stands their character down, while the world and everyone else carry on.
- Settings are per player. They apply immediately and are saved with the player, so they are back when the player rejoins.
- It works with keyboard, gamepad and touch, down to phone-sized screens. Each screen is a fixed screen-space area at the window's corner, so its backdrop covers the whole display, under the notch and the home bar, while its text and buttons stay inside the safe area (plus any `insets` the game asks for). This was checked with simulated safe areas in the editor, not on a real phone; see *Known limits*.
- Every word on screen comes from a string table. English, Brazilian Portuguese and Spanish ship with it, the language follows the player, and settings has a picker.
Version **1.3.1**.
## Install and use
Install menusKit and you are done. It is one install:
```lua
tools.use("zeromind", "installAsset", "61aaf7c0-6b6e-4451-9141-a5ef7a087272") -- menusKit
```
menusKit uses **controlsKit** (from Thumbstick Studio, guid `889ac3ad-87d6-427a-8bc4-3e955943fa6c`) for the button-prompt glyphs, keyboard and pad reading, and the button-rebinding screen. You don't install it yourself:
- **It comes in by itself.** controlsKit is a declared dependency, so the install brings it along, at `/source/deps/<id>/controlsKit.package`. If your world already has controlsKit, that copy is used.
- **Any folder works.** Install menusKit wherever you like (`/source`, `/source/games/myGame`, ...). The two kits find each other by asset name (`menusKit.menus`, `controlsKit.controls`), not by path, so nothing has to sit in a particular place.
You can also search ZeroMind for "menusKit" (home world: *Press Start Studio*, guid `37a0d1f9-9896-4626-b140-43fea7fbedeb`) and install it from there.
Then paste this at the end of your main scene's entrypoint (`/source/scenes/main.scene/entrypoint.luau` in a new world; the rest of that file is comments, so it can stay). It runs as written, in edit and in play, with no other code:
```lua
local Menus = require("menusKit.menus")
-- Your game's own actions. Replace the prints with your code.
local function startRun()
print("Start the run")
end
local function restartLevel()
print("Restart the level")
end
local function leaveGame()
print("Leave the game")
end
-- onLoad runs when the scene starts in play.
function onLoad()
Menus.on("play", startRun) -- the menu closes by itself
Menus.on("retry", restartLevel)
Menus.on("quit", leaveGame)
Menus.showTitle({ title = "My Game", theme = "pixel" })
end
```
Pressing Play prints "Start the run" and closes the menu. The same calls work from any script or component. A handler added while play is running lasts until play stops, so registering in `onLoad` each time play starts never doubles up; one added in edit mode (a module's top level) stays. Later, from your game:
```lua
local Menus = require("menusKit.menus")
Menus.showGameOver({ score = 12040, best = 15500 }) -- Retry calls restartLevel
Menus.showVictory({ score = 48210, time = 271, stars = 2 })
Menus.setTheme("horror") -- every screen restyles at once
Menus.setLayout("radial") -- every screen re-arranges at once
```
That is all a game needs. Pause, settings, the touch pause button, sounds, transitions, saving and translation all work without further code. The kit spawns its own local host (`MenusHost`) the first time it is used in play, so there is nothing to place in a scene.
`Menus.show(name, opts)` opens any screen by name (`"title"`, `"pause"`, `"settings"`, `"gameover"`, `"victory"`, `"loading"`, `"credits"`) with the same options as its own call. For settings, `opts.tab` picks the tab.
### Add it in the editor
With no code at all, add the **`menusKit.MenusScreen`** component to any entity in a scene, or to the player prototype. Its inspector fields are:
| Field | What it picks | Default |
|---|---|---|
| `theme` | the look, from a dropdown of all 8 | `scifi` |
| `layout` | the layout, from a dropdown of all 4 | `list` |
| `startScreen` | the screen it opens when play starts: `title`, `loading`, `settings`, `credits`, `gameover`, `victory`, `pause` or `none` | `title` |
| `title` | the game name on the title screen (blank shows "menusKit") | blank |
When play starts it styles every screen and opens the start screen for the local player. Changing `theme` or `layout` while playing restyles every open screen at once. On a player prototype only the local player's copy runs. Your game still listens for choices in code, with `Menus.on("play", fn)`.
## Looks
`Menus.themes()` lists them (it returns the same list as `Menus.looks()`; each entry's `name` is what `setTheme` takes), and `Menus.describe(name)` returns one. Every look styles all 7 screens with its own fonts, colours, frames, focus treatment, enter/exit transitions, ambient motion and four synthesised UI sounds (move, confirm, back, open).
| Name | Look | What it is like | Fonts (title / body) | Mood | Fits | Best input |
|---|---|---|---|---|---|---|
| `scifi` | Sci-Fi | Holographic HUD panels, chamfered glass, cyan glow and a scanning line. | Archivo Black / JetBrains Mono | sleek, tense, high-tech | shooter, space, strategy, racing | gamepad, keyboard |
| `fantasy` | Fantasy Parchment | An illuminated scroll on dark oak: serif ink, gilt rules, a wax-red ribbon for the chosen line. | EB Garamond / Gelasio | epic, storybook, warm | rpg, adventure, strategy, card | mouse, keyboard, touch |
| `cartoon` | Cartoon | Thick ink outlines, candy colours, chunky drop shadows and everything bounces. | Archivo Black / Comic Neue | playful, bright, silly | party, platformer, kids, casual | touch, gamepad |
| `pixel` | Pixel Retro | 8-bit arcade: stepped frames, chunky shadows, a blinking cursor and CRT scanlines. | Cousine / Cousine | nostalgic, arcade, upbeat | platformer, arcade, roguelike, puzzle | gamepad, keyboard |
| `minimal` | Minimal | Quiet editorial white: one typeface, hairlines, generous space and a single red accent. | Arimo / Arimo | calm, elegant, focused | puzzle, narrative, indie, sports | mouse, touch, keyboard |
| `military` | Military | Tactical briefing: olive drab, stencil caps, hazard stripes, numbered orders and an amber cursor. | Anton / Cousine | gritty, serious, tactical | shooter, strategy, survival, simulation | gamepad, keyboard |
| `horror` | Horror | Candle-dark and wrong: blood-red serif, a vignette that breathes, drifting fog and lights that flicker. | PT Serif / Tinos | dread, dark, unsettling | horror, survival, thriller, mystery | keyboard, gamepad |
| `cozy` | Cozy | Warm cottage afternoon: soft rounded cards, peach and sage, floating petals and a gentle bob. | Bricolage Grotesque / Arimo | warm, gentle, relaxing | farming, life-sim, puzzle, crafting | touch, mouse, gamepad |
Aliases work everywhere a name does: `sci-fi`, `parchment`, `toon`, `retro`, `8bit`, `pixel retro`, `clean`, `army`, `spooky` and `warm`.
## Layouts
`Menus.layouts()` lists them. A layout arranges the choice screens (title, pause, game over, victory) on top of any look. Settings, loading and credits keep their own arrangement, which adapts to the screen shape.
| Name | Layout | What it is like | Mood | Fits | Best input |
|---|---|---|---|---|---|
| `list` | Centred List | Title on top, a framed column of choices in the middle. The classic; reads instantly on any device. | classic, clear | any | any |
| `rail` | Side Rail | A tall panel down the left edge holds the title and choices; the rest of the screen stays open for the game behind it. | cinematic, modern | action, shooter, racing, open-world | mouse, keyboard |
| `carousel` | Card Carousel | Big illustrated cards slide sideways with the focused one centred and its neighbours peeking in. Made for thumbs and shoulder buttons. | playful, showcase | party, casual, mobile, fighting, kart | touch, gamepad |
| `radial` | Radial Menu | Choices ring a central hub; point the stick at one to pick it, the hub names it. Fast and satisfying on a gamepad. | dynamic, tactile | action, shooter, adventure, console | gamepad |
Aliases: `centered`, `centred`, `column`, `side`, `sidebar`, `cards`, `card`, `wheel`, `ring`, `pie`.
### Picking one by tags
```lua
local best = Menus.match({ mood = "dark", genre = "survival", input = "gamepad", kind = "look" })
Menus.setTheme(best[1].name) -- "horror"
local all = Menus.catalogue() -- { version, looks, layouts, screens, events, languages, settings }
```
## Screens
| Screen | Call | Default choices | Notes |
|---|---|---|---|
| Title | `showTitle{ title, subtitle, version, canContinue, items, theme, layout, credits }` | play, continue, settings, credits, quit | Continue is greyed out unless `canContinue = true`. Replaces whatever was open. |
| Pause | `showPause{ items, restart }` | resume, settings, mainMenu, quit | Opens by itself on Escape / P / Start / the touch button. `restart = true` adds Restart. |
| Settings | `showSettings(tab)` | tabs: audio, controls, graphics, language | Opened from the Settings choice of title or pause. Back returns there. |
| Game over | `showGameOver{ score, best, time, title, subtitle, items }` | retry, mainMenu | `time` is in seconds and shown as mm:ss. |
| Victory | `showVictory{ score, best, time, stars, maxStars, stats, title, subtitle, items }` | next, retry, mainMenu | `stats = {{ label, value }}` adds lines. Stars are out of 3 by default. |
| Loading | `showLoading{ title, tips, progress, tipSeconds }` | – | Tips rotate every `tipSeconds` (4.5). `progress = nil` shows a looping bar. Drive it with `setProgress(p)`. |
| Credits | `showCredits{ title, sections, speed, thanks, loop }` | – | `sections = {{ title = "Design", names = { "Ada Park" } }}`. It scrolls at `speed` pt/s (46) and ends by itself. |
`items` replaces a screen's choices. Each one is an id string, or `{ id, text, desc, icon, disabled }`. `text` is a string key or plain text, and choosing it emits an event named after its id.
```lua
Menus.showPause({ items = { "resume", { id = "shop", text = "Shop", icon = "storefront" }, "settings", "quit" } })
Menus.on("shop", openShop)
```
## Events
`Menus.on(event, fn)` returns a handle for `Menus.off(handle)`. `fn(info)` receives `info = { id, screen, opts, ... }`.
```lua
Menus.on("settingsChanged", function(info)
print(info.key, info.value) -- e.g. "music", 4
end)
```
| Event | When |
|---|---|
| `play`, `continue`, `settings`, `credits`, `quit` | The title's choices. |
| `resume`, `restart`, `mainMenu` | The pause menu's choices. `resume` also fires whenever pause closes. |
| `retry`, `next` | Game over and victory. |
| `pause` | This player paused (any way). |
| `back` | The player went back one screen (`info.screen` is the one that closed). |
| `select` | Any choice was picked (`info.id`, `info.screen`), before its own event. |
| `show` / `hide` | A screen was pushed or shown (`info.screen`) / every menu closed. |
| `settingsChanged` | A setting really changed (`info.key`, `info.value` = the new value). It fires for every way a setting changes: keyboard, gamepad or touch on the settings screen, Reset to defaults (once per setting it changed), the game calling `setSetting` / `setLanguage`, and a saved copy that arrives late. Setting a value it already has (or one that clamps to it) fires nothing. Rebound buttons fire it with `info.key = "bindings"` and the bindings now in use (`{ scheme, bindings }`), when the rebinding screen closes and when the controls tab is reset. |
| `creditsEnd` | The credits finished (`info.skipped = false`) or the player backed out (`true`). It fires exactly once. |
| `loadingDone` | `setProgress` reached 1. It fires once per loading screen. |
| `theme` / `layout` | The look or layout changed (`info.name`). |
**Event names are checked.** `Menus.on` raises at once on a name that doesn't exist, so a typo can't leave a game that never reacts. The error lists every event name that exists and suggests the closest one:
```
menusKit.on: unknown event 'settingChanged'. Did you mean 'settingsChanged'? Events: play, continue, ... For a custom menu choice, show it on a screen first or declare it with Menus.addEvents("settingChanged").
```
**Custom choices** are events named after their id. `on` accepts one once a screen has shown it (as in the Shop example under *Screens*), or once the game has declared it with `Menus.addEvents("shop")`. Declare it first when you subscribe before opening the screen:
```lua
Menus.addEvents("shop")
Menus.on("shop", openShop)
-- later
Menus.showPause({ items = { "resume", { id = "shop", text = "Shop" }, "settings", "quit" } })
```
**Old name.** `setting` was the 1.2.0 name of `settingsChanged`. It still works in 1.x as an alias (`Menus.EVENT_ALIASES`): `Menus.on("setting", fn)` gets the same events and prints a warning once telling you to switch to `settingsChanged`. It will be removed in 2.0.
With the default `autoClose = true`, picking play, continue, retry, next or restart closes the menus. mainMenu, and quit from anywhere but the title, returns to the last title screen. Settings and credits open their screens. Your game only has to react.
## Settings
Each player has their own settings. A change applies the moment it is made and is saved to that player's own data (`runtime_data.player`), so it is restored when they come back, whichever session of the world they land in.
How the saved copy behaves:
- One leaf per setting under `menusKit/settings/` (`menusKit/settings/music`, …). Only what the player changed is written; a setting they never touched is never saved, so a later default change reaches them.
- A saved copy that reaches the machine after the menus opened (the player's data can arrive a moment after they join) is taken up when it arrives and applied. It is never overwritten by the defaults that stood in for it.
- A player identity that arrives late is picked up too: what was changed before it is saved once it exists.
- Rebound buttons are saved as `menusKit/settings/bindings`, together with the scheme they belong to. They are imported when that controlsKit scheme is active, or as soon as it becomes active; menusKit never switches the game's scheme to apply them.
- The single table menusKit 1.0.0 saved at `menusKit/settings` is still read. A leaf wins over it.
- `MenusHost` drives all of this by calling `Settings.tick(dt)` every frame. A game that only uses `menusKit.settings` without the host calls `Settings.load()` once and `Settings.tick(dt)` from its own update.
| Key | Values (default) | What it changes |
|---|---|---|
| `master` | 0–10 (8) | Master volume. |
| `music` | 0–10 (7) | The `music` channel. |
| `effects` | 0–10 (8) | The `sfx` and `ui` channels. |
| `voice` | 0–10 (8) | The `voice` and `dialogue` channels. |
| `sensitivity` | 1–10 (5) | Camera look speed. `Menus.lookScale()` gives the multiplier (1.0 at 5, 2.0 at 10) for games with their own camera. |
| `invertY` | true / false (false) | Inverts the camera's vertical look. |
| `prompts` | auto, keyboard, xbox, playstation, switch, generic (auto) | Which controlsKit glyph set the prompts show. |
| `quality` | auto, low, medium, high (auto) | The renderer's quality tier. |
| `language` | auto, en, pt-BR, es, … (auto) | The menus' language. `auto` follows the player's own. |
The controls tab also has **Rebind buttons**, which opens controlsKit's rebinding screen for the scheme the game uses. It is greyed out when the game has no controlsKit scheme active. Each tab has **Reset to defaults**.
```lua
Menus.setSetting("music", 3)
print(Menus.getSetting("quality"), Menus.settings().language)
```
## Input
| | Keyboard | Gamepad | Touch / mouse |
|---|---|---|---|
| Pause / back out | Escape, P | Start | the pause button (top right, touch only) |
| Move focus | arrows, WASD | left stick, d-pad | tap / hover; swipe the cards sideways (carousel) |
| Choose | Enter, Space | A (south) | tap / click |
| Back | Backspace | B (east) | the on-screen back button |
| Settings tab | Q / E | LB / RB | tap the tab |
| Radial layout | arrows | point the stick at a choice | tap |
On the card carousel a sideways drag on the cards moves one card per step (with a finger or a held mouse button), and lifting the finger at the end of a swipe never counts as a tap. A tap without a drag picks the card drawn under the finger. While the cards slide to a new focus (0.34 s), a tap goes to the card whose resting place is under the finger, which can be the neighbour of the card drawn there mid-slide.
Prompts along the bottom show the right glyphs for the device in use (from controlsKit) and hide on touch. While any menu is open, `Menus.inputBlocked()` is true. The kit's input map stands gameplay input down for that player, and the player's controlsKit controller is released, so a controlsKit-driven character stops. A game reading devices itself should check `Menus.inputBlocked()`.
The `pause` option of `setup` turns Escape/Start pausing off, and `pauseButton = "auto" | true | false` controls the touch pause button.
### With controlsKit
menusKit uses controlsKit for prompt glyphs, pause/back/confirm and the rebinding screen. It arrives with menusKit as a dependency and is found by name wherever it lands. Two things to know when your game also drives its character with a controlsKit scheme:
- **One pause button on touch.** Every controlsKit scheme draws its own touch-corner pause button, and menusKit doesn't listen to it. Register a copy of your scheme without it, so a phone shows menusKit's button only:
```lua
local Schemes = require("controlsKit.schemes")
local s = Schemes.get("platformer") -- a fresh copy, safe to change
s.touch.corner = {}
Schemes.register("myGame", s)
require("controlsKit.controls").use("myGame")
```
Or keep controlsKit's button and call `Menus.setup({ pauseButton = false })`.
- **Builtin touch controls drawing over controlsKit's (editor only).** controlsKit remembers its takeover claim in module state, which survives an editor Play → Edit → Play, but the engine drops the claim when play stops. On the second play the builtin Sprint/Jump/stick controls come back on top. A published game's first session is unaffected. To be safe, activate an input map of your own with `suppresses = { "player" }` and at least one (empty) binding while your character is in play. The gallery's `gallery.galleryWalk` map does exactly this.
## Languages
All text comes from string tables in `menusKit.strings`: English (`en`), Brazilian Portuguese (`pt-BR`) and Spanish (`es`). Open screens re-translate the moment the language changes.
```lua
Menus.setLanguage("es") -- or "auto" to follow the player
Menus.addLanguage("fr", "Français", { ["menu.play"] = "Jouer", ["menu.quit"] = "Quitter" })
Menus.text("result.best", { best = "15,500" }) -- "Best: 15,500"
Menus.showTitle({ title = { en = "Star Forge", es = "Forja Estelar" } }) -- inline translations
```
Missing keys fall back to English. `require("menusKit.strings").keys()` lists every key a translation should cover.
## API
### `menusKit.menus` — what games use
**Constants**
- `VERSION` (`"1.3.1"`), `SCREENS`, `EVENTS`, `DEFAULT_ITEMS`.
- `EVENT_ALIASES`: old event names that still work, and the name each stands for (`{ setting = "settingsChanged" }`).
- `SCREEN` / `PAUSE_SCREEN`: the ui screen ids `menusKit_menu` and `menusKit_pauseBtn`.
- `LAYER` (950): the ui layer the menu screen registers at. Its root is a screen-space `area`, so a game's own screens at layer 1000 or above draw over the menus, and anything below 950 draws under them. The touch pause button sits at `LAYER - 10`.
**Screens**
- `showTitle(opts)`, `showPause(opts)`, `resume()`, `showSettings(tab?)`, `showGameOver(opts)`, `showVictory(opts)`.
- `showLoading(opts)`, `setProgress(p)`, `showCredits(opts)`.
- `show(name, opts?)`: any of the above by name; `opts.tab` picks the settings tab. Raises on an unknown name, listing the screens.
- `hide()`: closes everything.
- `back() -> boolean`: false on a screen with nothing behind it.
**State**
- `current() -> string?`, `stack() -> {string}`.
- `isOpen()`, `isPaused()`, `inputBlocked()`.
**Events**
- `on(event, fn) -> handle`, `off(handle) -> boolean`. `on` raises on an event name that doesn't exist.
- `events() -> {string}`: every name `on` accepts right now (`EVENTS`, then the custom choice ids shown or declared).
- `addEvents(...)`: declare custom choice ids (strings or lists of strings) so `on` accepts them before a screen shows them.
**Style**
- `setTheme(name, quiet?)`, `setLayout(name, quiet?)`, `theme()`, `layout()`.
- `setup(cfg)` (alias `configure`) with `{ theme = "scifi", layout = "list", pause = true, pauseButton = "auto", sounds = true, soundLevel = 1, autoClose = true, credits = nil, transitions = true, insets = nil }`. `insets = { top, left, right, bottom }` keeps that many extra points clear on every screen and around the touch pause button, for a game HUD that must stay visible.
- `config()`: a copy of the current options.
**Catalogue**
- `looks()`, `themes()` (the same list as `looks()`), `layouts()`, `describe(name)`.
- `match{ mood, genre, input, kind }`: sorted best first, each entry with a `score`.
- `catalogue()`.
**Settings and text**
- `settings()`, `getSetting(key)`, `setSetting(key, value)`, `lookScale()`. `getSetting("bindings")` returns a copy of the controlsKit bindings in use (`{ scheme, bindings }`), or nil with no scheme active.
- `text(key|table, vars?)`, `addLanguage(tag, name, strings)`, `setLanguage(tag)`, `language()`.
**Navigation** (what the host calls; handy for tests and custom input)
- `navigate("up"|"down"|"left"|"right") -> boolean`.
- `focus(i) -> boolean`, `focused() -> (index, id)`, `items()`, `select()`.
- `tab(dir|name)`, `togglePause()`.
- `handle(uiId) -> boolean`: the screens' click ids, `mk:item:<i>`, `mk:back`, `mk:set:<key>:inc` and so on.
**Rendering** (what the host draws)
- `build(w, h, safe?, { prompt, touch }) -> tree?`.
- `buildPauseButton(w, h, safe?) -> tree?`.
Functions starting with `_` are internal.
### Supporting modules
These are mostly internal, but stable enough to extend:
- **`menusKit.themes`**: the 8 looks.
- `list()`, `get(name)`, `canonical(name)`, `describe(name)`.
- `fitTitle(look, ctx, text, size)`, `helpers`.
- `ICONS`: the icon each default choice uses. `icon(ref, size, color)` draws an icon node (the same as `Icons.node`).
- `fonts(name) -> {string}`: the font families a look uses. MenusHost checks they are registered and re-registers the builtin fonts if a web build's font loading raced the first frame, so every look shows its own font.
- **`menusKit.icons`**: the kit's own 32 SVG icons, drawn in the text colour. They need no icon font, so they show the same in the editor, a native build and the web player.
- `names()`, `aliases()` (for example `storefront` draws `shop`), `has(ref)`.
- `svg(ref, opts?) -> string?`, `node(ref, size, color?, opts?) -> tree?`.
- An item's `icon` can be any of these names, an alias, or an inline `<svg>` document.
- **`menusKit.layouts`**: the 4 layouts.
- `list()`, `get(name)`, `canonical(name)`, `describe(name)`.
- Each layout has `compose`, `nav` and `stepKeys`. The carousel also has `metrics(ctx, n, reserve?)`; the radial has `angle`, `pick` and `metrics(ctx, n, hasTitle, subH?)`. The optional height is the room the screen keeps for its title, score block and prompts, so the cards or the ring shrink to fit.
- **`menusKit.screens`**: builds each screen's widget tree.
- `NAMES`, `TABS`, `context(w, h, safe?, extra?)`, `build(name, state)`.
- `context` returns `w`/`h` (the space content may use), `fullW`/`fullH` (the whole screen), `compact`, `portrait`, `safe`, `extra` and `inset` (safe + extra on each side). A screen's root is a fixed `area` at the window's origin (screen space, not moved by the safe area); inside it a full-screen stage draws the backdrop to the screen edges, and the content sits at `inset`.
- One builder per screen, plus `promptStrip`, `tipText`, `pauseButton`.
- Credits: `defaultCredits()`, `creditsHeight(sections)`.
- **`menusKit.settings`**: per-player settings.
- `KEY`, `DEFAULTS`, `PROMPTS`, `QUALITY`, `TABS`.
- `get`, `all`, `set`, `step`, `reset(tab?)`, `choices`, `onChange` / `offChange`.
- `save()` (false when the player has no identity to save under), `load()` (true when a saved copy was found), `isLoaded`, `tick(dt)` (takes up a late saved copy or identity; MenusHost calls it).
- `applyAll`, `applyCamera`, `sensitivityScale`.
- `rows(tab)`, `valueText`.
- Test hooks: `_useStore(fn)` saves and loads through `fn()` instead of the player's data (`_useStore(nil)` puts the live session back); `_forget()` starts over as a player who has just joined.
- **`menusKit.strings`**: string tables.
- `BUILTIN`, `t(key, vars)`, `has`, `keys`.
- `register(tag, name, strings)`, `languages()`, `language()`, `setLanguage`, `resolve`.
- `number(n, decimals)`: locale digit grouping.
- **`menusKit.sounds`**: UI sounds.
- `KINDS`, `clip(look, kind)`, `play(look, kind)`, `setEnabled(on, level)`, `isEnabled()`.
- `synth`, `bake`, `clipName`. The 32 clips are pre-baked under `sounds/`.
- **`menusKit.MenusHost`** (component): spawned automatically. It draws the screens, reads keys, pads and swipes, and routes clicks.
- **`menusKit.MenusScreen`** (component): the editor set-up described in *Add it in the editor*.
- **`menusKit.menuInputBlock`** (inputMap): the map that stands gameplay input down while a menu is open.
## Tests
`menusKitTests` covers every public call: the screen stack, events, looks and layouts, settings, languages, navigation, drawing every screen × look × layout on desktop and phone shapes, and each module. Run it in edit mode with `tools.use("tests", "run", { suite = "menusKitTests", onWorker = true })` (120 tests). `onWorker` runs it on a fresh module graph; without it, it runs on the engine thread against the modules already loaded. The suite saves and loads through a stand-in store and pins its own language, so it passes the same on any engine whatever the player running it has saved, and never touches their real settings.
## Changelog
- **1.3.1**: the first README example works when pasted.
- **Self-contained example.** The first example used `startRun`, `restartLevel` and `leaveGame` without defining them, so the strict checker flagged three unknown names and the world refused to enter play or publish. It now defines all three, says where to paste it (the main scene's entrypoint), shows the title from `onLoad`, and keeps the Game Over, Victory, theme and layout calls in a second snippet. It was checked in play, with the strict checker on.
- **Handlers no longer pile up between play runs.** A handler added with `Menus.on` while play is running now ends when play stops. Before, registering in `onLoad` added another copy every time play started, so Play fired twice on the second run, three times on the third, and so on. Handlers added in edit mode are kept as before.
- **Tests.** 4 new: play-session handlers, no pile-up across runs, and the README's first example both passing the strict checker and running as written (Play, Retry and Quit reach the game's own functions).
- **1.3.0**: `settingsChanged`, and event names are checked.
- **`settingsChanged`.** The settings event now has the name it was asked for. It fires on every real change, whether by keyboard, gamepad or touch on the settings screen, by Reset to defaults, or from code, with `info.key` and the new `info.value`. Rebound buttons now fire it too (`key = "bindings"`); before, a rebind or a controls reset was saved without any event. `getSetting("bindings")` reads the bindings in use.
- **Old name kept.** `setting` still works as a documented alias that warns once (`EVENT_ALIASES`). It will be removed in 2.0.
- **Unknown event names raise.** `Menus.on("settingChanged", fn)` used to be accepted and never fire. It now raises at once, listing the event names and suggesting the closest one. A non-string name or a non-function `fn` raises too.
- **Custom choices.** Their ids are still events. `on` accepts one once a screen has shown it, or after `Menus.addEvents(id)`. **Upgrading:** a game that called `Menus.on("shop", fn)` before any screen showed a Shop choice now gets an error; add `Menus.addEvents("shop")` first. `Menus.events()` lists every accepted name.
- **Gallery.** The gallery's settings toast listens to `settingsChanged`.
- **Tests.** 116 tests (9 new: each way a setting changes, the alias, unknown names, custom ids, `addEvents`, rebinds).
- **1.2.0**: the themes list, the title keeps its theme, and two 1.1.0 promises made true.
- **Themes list.** Adds `Menus.themes()`, the same catalogue entries as `looks()` under the name `setTheme` uses. `looks()` is unchanged.
- **Main Menu keeps the current look.** A `theme` or `layout` passed to `showTitle` is applied once and no longer stored with the title. Before, Main Menu (or Quit from pause) brought the title back in the look it was first opened with, undoing any `setTheme` since.
- **Full-bleed backdrop, for real.** 1.1.0 said the backdrop covered the area under the notch and home bar, but on a phone-shaped screen it stopped at the safe area: the stage reached out under the top bar, but the band under the home bar was never painted. Each screen's root is now a fixed `area` at the window's origin, and the backdrop covers the whole screen. Because of this, `LAYER` moves from 1100 to 950, so a game's overlays at 1000+ stay above the menus.
- **Carousel taps.** 1.1.0 said taps worked as before, but a tap on a card picked its neighbour: the card row was moved with a transform, which touches don't follow. The row is now placed by layout and only the slide animation uses a transform, so a tap picks the card under the finger.
- **Room for the score.** The radial ring and the carousel cards now leave room for the game over and victory score block and the prompt strip. Before, the radial's lower choice and its caption ran off the bottom on most screen heights. Screens also go compact below 520 pt of height (was 480).
- **Gallery.** The gallery title shows the kit's current version.
- **Tests.** 107 tests.
- **1.1.0**: phones, touch, fonts and the editor.
- **Safe area.** The backdrop now covers the whole screen, under the notch and home bar, and content stays inside the safe area plus the game's `insets`. Before, a strip at the bottom was bare, and with a large safe area the radial's lower button and the foot of the credits panel were cut off.
- **Radial hub.** The centre is now a disc capped at 220 pt and kept clear of the ring in any window shape. Before, a tall window turned it into a large rounded square running into the choices. The focused choice is named once: by the hub, or by a caption when the hub is too small.
- **Carousel swipe.** Swiping the cards sideways moves the focus on touch and with a held mouse button. Taps work as before.
- **Icons and fonts.** The kit draws its own SVG icons instead of the editor's icon font, so icons no longer show as empty boxes in a play session. MenusHost re-registers the builtin fonts when a look's font is missing, so the pause screen keeps its look's font.
- **Editor.** Adds the `MenusScreen` component (theme, layout, start screen, title) and `Menus.show(name, opts)`.
- **Input map.** The input map is renamed `menuInputBlock` (was `menus`).
- **Tests.** The tests no longer depend on the running player's saved language or settings; 102 tests.
- **Install docs.** The install notes now say where controlsKit lands: it arrives as a dependency under `/source/deps/`, or the world's own copy is used.
- **1.0.1**: settings survive a rejoin however late the player's data arrives.
- Each setting is now its own leaf, and only changed settings are written. Before, defaults that loaded ahead of the saved copy could be written over it.
- A late saved copy or a late player identity is taken up (`Settings.tick`).
- Rebound buttons are kept per scheme and imported when that scheme becomes active.
- The 1.0.0 table is still read.
- The tests use a stand-in store; 89 tests.
- **1.0.0**: first release.
## Known limits
- **Safe area.** The backdrop covers the whole screen and text and buttons stay inside the safe area plus `insets`. This was verified in the editor with `ui.simulateSafeArea` (a notch, a home bar and a side inset) by rendering every screen and measuring its layout. It has not been tried on a real phone, and `ui.simulateSafeArea` has no effect in a play session, so the play-mode checks ran with no safe area.
- **Carousel taps.** A tap picks the card drawn under the finger. This was verified in a play session with simulated touches (a tap on the second card picks the second card), not on a real touch screen. While the cards slide after the focus moves (0.34 s), a tap lands on where the cards are coming to rest.
- **Short screens.** Measured in the editor at 1280 pt wide with simulated safe areas, a few screens still run past the bottom of the safe area. With the keyboard or gamepad prompt strip showing, the strip ends up to 44 pt below it on the list victory screen at about 540 pt of usable height (and 8–15 pt on the list title in the cartoon and military looks). It ends up to 32 pt below on the carousel victory and game over screens at about 360 pt, and 5–28 pt on the side-rail title at about 360 pt. On touch, where there is no prompt strip, only the list victory screen at about 540 pt (9 pt in fantasy, 14 pt in military) and the carousel victory screen at about 360 pt (2 pt) overflow. Where the screen has a bottom inset the overflow lands under the home bar; with none it is clipped at the screen edge. The radial layout fits at every height measured.
- **Clipped under the safe-area insets in the editor (engine issue #19210).** While playing inside the editor with phone safe-area insets, a menu element that sits under the insets (for example the backdrop under the notch or home bar) can show clipped. It is an engine issue with the editor's play view, not a menusKit layout bug; the layout itself was measured to reach the screen edges.
- One menu stack per client. Each player runs their own, which is what makes pause per-player.
- Rebinding needs the game to use a controlsKit scheme.
- A guest with no player identity can't keep settings between visits. Their choices apply for the session and `save()` returns false.
- A game with its own character controller must read `inputBlocked()` itself if it doesn't go through input maps or controlsKit.