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…
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:
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:
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
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:
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:
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).
Scoped to this part · feeds back into the world's score.