Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
Controllers
Horizon plays with PlayStation (DualShock 4, DualSense), Xbox (360, One, Series) and Nintendo Switch Pro controllers, and on the Steam Deck with its built-in controls. Keyboard and mouse keep working exactly as before: with no controller connected, or with controllers turned off, nothing changes.
What vanilla has
Nothing. Minecraft Java 26.3 never calls GLFW's gamepad or joystick API, has
no controller options and ships no button glyphs. Its only non-mouse UI input
is keyboard focus navigation (InputType.KEYBOARD_ARROW / KEYBOARD_TAB:
arrows and Tab move a focus ring between widgets). So controller support is
Horizon's own feature. It follows what players already know from Minecraft on
consoles (Bedrock Edition's default controller layout) and Horizon's menu
design for any new screen.
Input layer
gilrs reads the pads: evdev on Linux (with libudev for hot-plugging), IOKit on macOS, Windows.Gaming.Input on Windows. Its SDL mapping database normalizes every pad to one positional layout (South/East/West/North face buttons, bumpers, triggers, sticks, D-pad, Select/Start). No second windowing or event stack: gilrs is polled once per loop iteration next to winit's events.
- The pure logic (mapping table, deadzones and curves, button edges, the
settings file, glyph choice) lives in
crates/horizon-client/src/gamepad.rsand is unit-tested. The game owns a thin gilrs backend and applies actions. - Controller actions go through the same path a keyboard key takes after
rebinding: an action names a vanilla key mapping (
key.jump,key.attack, ...), and the game handles it as that mapping's default key or mouse button. Toggle sneak/sprint/attack/use, screens releasing held keys, and the multiplayer client all behave as they do on the keyboard. - Movement is analog: the left stick's direction and tilt feed the player's move vector directly (a half-tilted stick walks slowly, like Bedrock), instead of being rounded to WASD. The forward/back/left/right flags other code reads (sprint start, boats, minecarts, the server's input packet) are set when the stick passes a threshold.
- The pad last touched is the active one. Several pads are not merged.
horizon-ctl pad ...injects a virtual pad (buttons, sticks, triggers) into a running game, for tests without hardware.
Default mapping
| Action | Xbox | PlayStation | Mapping |
|---|---|---|---|
| Move | Left stick | Left stick | analog key.forward/back/left/right |
| Look | Right stick | Right stick | camera |
| Jump / fly up | A | ✕ | key.jump |
| Drop item | B | ○ | key.drop |
| Swap offhand | X | □ | key.swapOffhand |
| Inventory | Y | △ | key.inventory |
| Hotbar left / right | LB / RB | L1 / R1 | Horizon pad.hotbarPrevious/Next |
| Use / place | LT | L2 | key.use |
| Attack / break | RT | R2 | key.attack |
| Sprint | Left stick click | L3 | key.sprint |
| Sneak (toggles) | Right stick click | R3 | key.sneak |
| Change camera | D-pad up | D-pad up | key.togglePerspective |
| Pick block | D-pad left | D-pad left | key.pickItem |
| Chat | D-pad right | D-pad right | key.chat |
| Advancements | D-pad down | D-pad down | key.advancements |
| Player list (hold) | View | Create / Share | key.playerlist |
| Pause / back | Menu | Options | Escape (fixed) |
The Guide / PS / Steam button is left to the system. Sneak on a stick click toggles by default (holding a stick in while steering the camera is awkward, which is why Bedrock does the same); the controls options can make it hold-to-sneak.
Menus and inventories
Vanilla's screens are all mouse-driven, and Horizon draws them 1:1. A pad
drives them in one of two ways, picked in the controller options ("Menu
Navigation: Focus / Pointer", menuNavigation:focus|pointer):
- Focus (the default): a console-style highlight that the D-pad and the left stick move between buttons, sliders, switches, text fields, list rows, tabs and inventory slots.
- Pointer: a virtual cursor the left stick moves, as Bedrock's classic inventory UI and Java's controller mods do.
Focus is the default because it is what a pad player expects from a console game, it needs no aiming (every widget is one D-pad press away, which also suits the Steam Deck's small screen), and it works from the couch. The pointer stays one option away, and a right stick click swaps the two for the open screen, for the few places a pointer is better (dragging the advancements map, painting a crafting grid with a held stack).
Both modes feed the screens the same pointer events a mouse sends: in focus mode the (hidden) pointer sits on the focused widget. So every screen keeps its mouse behaviour (drags, sliders, scrollbars, hover highlights) with no controller code of its own, and both modes reach every screen. Moving the real mouse hands control back at once.
Focus navigation
Every interactive rectangle a screen draws registers itself as a focus
target with its kind (widgets::target_as): classic buttons, sliders, edit
boxes and list rows (worlds, servers, languages), every Horizon-style widget
(ui_theme::Ui::interact: sidebar items, switches, segments, sliders,
fields, buttons), inventory and container slots, creative and recipe-book
tabs, and the stations' own buttons (trades, loom patterns, beacon effects,
enchantments, stonecutter recipes, the recipe book's recipes, pages and
filter). A screen that registers nothing falls back to the pointer.
The step itself is vanilla's arrow-key navigation
(ContainerEventHandler.nextFocusPathInDirection, 26.3), applied to every
target of the screen at once: from a strip one pixel past the focused edge,
the nearest target that overlaps it on the other axis wins (ties go to the
leftmost or topmost); without one, the target whose facing edge's centre is
nearest the focused edge's centre (nextFocusPathVaguelyInDirection). That
gives vanilla-like jumps in containers too: hotbar to inventory, the grid to
the crafting result, armour to the player's grid.
| Input | Focus mode |
|---|---|
| D-pad, left stick | move focus (held: repeats after 0.35 s, every 0.09 s) |
| A | press the focused widget (held: drag); on a text field, type with the on-screen keyboard |
| B, Menu | back (Escape) |
| X | right click (inventories: take half, place one) |
| Y | quick move (shift-click) in inventories |
| View | throw one item from the focused slot (Q) |
| Left stick click | throw the focused stack (Ctrl+Q) |
| LB / RB | previous / next tab (creative, recipe book, the Horizon options' sections) |
| Right stick | scroll |
| Right stick click | swap focus and pointer for this screen |
- Sliders: left and right move the value (one pixel per press like vanilla's arrow keys, faster while held); A does nothing on a slider, as a click would jump the handle.
- Lists follow focus: a step past the last visible row scrolls the list and lands on the next row once it is drawn.
- Inventories: the carried stack is drawn on the focused slot (vanilla draws it under the pointer). A takes or places, X splits or places one, Y quick-moves, View and the left stick click throw.
- Where focus lands when a screen opens: the held hotbar slot in an inventory, else the first button.
How it looks follows the menu style:
- Classic screens look as vanilla's own keyboard focus does: buttons,
sliders and slots show their highlighted sprite (
isHoveredOrFocused, drawn because the hidden pointer is over them), and list rows and text fields get vanilla's white one-pixel outline (AbstractSelectionList's focused selection,EditBox's highlighted border). - Horizon-style screens get the design system's focus colour: a two-dp ring in the accent colour around the focused widget, pill-shaped on buttons, tabs and sliders.
Pointer
- Left stick moves the cursor with acceleration; in container screens the cursor is gently pulled toward the nearest slot centre.
- D-pad steps to the nearest target that way (a slot, a button) and snaps to it.
- A = left click, X = right click, Y = quick move, B = back; View and the left stick click throw, as in focus mode.
- LB / RB switch tabs; right stick scrolls.
- Sliders: hold A on the slider and move the stick, or nudge with the D-pad.
The cursor is drawn only while the pad is the last-used input.
Text fields
A on a focused text field (A in pointer mode clicks into it) opens the on-screen keyboard: a QWERTY grid and a symbols grid in Horizon's design language, at the bottom of the screen (at the top when the field is in the lower half, as chat is). D-pad or stick to move, A types, X deletes, Y is a space, LT is Shift (twice: caps lock), RB switches to symbols and accents, Menu is Enter, B closes it. It opens by itself when the pad opens chat or a sign. It types into every field the keyboard reaches: chat (local and server), signs, books, the title screens' fields, the options' searches, the creative and recipe-book searches, the anvil, the LAN port.
On a Steam Deck under Steam (SteamDeck=1), A asks Steam for its own
keyboard instead (steam://open/keyboard), which types like a real keyboard;
Steam + X opens it at any time. "On-Screen Keyboard: Auto / Horizon / Steam"
(keyboard:) picks one.
Screens covered
Both modes reach every screen through the pointer events. Focus mode registers targets on: the title menus (main, world list and its search, create and edit world, delete, multiplayer list and its edit screens), every options page (classic pages and the Horizon-style sections, key and controller binds, language list), pause, death, chat, signs and books (their buttons), the survival and creative inventories, every container and station (chests, furnaces, brewing, crafter, crafting table with its recipe book, anvil, grindstone, smithing, stonecutter, loom, cartography, enchanting, beacon, villager and wandering-trader trades, mount inventories), and the LAN / world options. Where a screen draws something clickable no target names (the advancements map, the statistics lists, the pack screen's rows, the book's page text), the right stick click gives the pointer for that screen.
Glyphs
Horizon draws its own button glyphs in the HUD and screens (no Mojang assets, nothing new from the player's jar): a small round or pill shape with the label in the game font, sized to the GUI scale, with two styles:
- Xbox / Steam Deck / generic: A B X Y in their usual colours, LB RB LT RT, View, Menu, stick icons.
- PlayStation: × ○ □ △ in their colours (× rather than ✕, which the game fonts lack), L1 R1 L2 R2, Create/Share, Options.
- Nintendo: B A Y X by position.
The style comes from the pad's USB vendor (Sony 054c, Microsoft 045e, Nintendo 057e, Valve 28de) unless the controls options force one. Glyphs replace key names where a prompt names a key (the controls list, tutorial hints, "Press X to dismount") only while the pad is the last-used input.
Options and remapping
Controller settings are Horizon's own and live in horizon-controller.txt
next to options.txt, like horizon-graphics.txt: options.txt stays
exactly vanilla's, so a vanilla client can share the game folder.
enabled:true
vibration:true
lookSensitivity:0.5
invertX:false
invertY:false
moveDeadzone:0.15
lookDeadzone:0.12
triggerThreshold:0.4
toggleSneak:true
glyphs:auto
bind_key.jump:south
bind_pad.hotbarNext:rb
Only bindings that differ from the defaults are written. The controls options get a Controller page: on/off, vibration, look sensitivity, invert X/Y, deadzones, glyph style, sneak toggle, and a binding list like the key binds screen (pick an action, press a button; conflicts marked as vanilla does; reset).
Deadzones are radial (the stick's length, so diagonals aren't clipped) and the remaining range is rescaled to 0..1. Look uses a squared curve for precise aim near the centre, up to 200°/s yaw and 140°/s pitch at the default sensitivity, slower while scoping with a spyglass.
Rumble
Vibration (on by default, off in the options) through gilrs force feedback: taking damage (stronger with the damage), a block broken, an explosion nearby (scaled by distance), a totem of undying, an elytra boost, a fish biting. Short, low-intensity pulses, as on Bedrock. Linux (evdev force feedback, including Steam Input's virtual pad) and Windows support it; macOS gilrs has no force feedback, so it is silently skipped there.
Steam Deck
- In Game Mode, or any game launched through Steam, Steam Input presents the Deck's controls as a virtual Xbox pad: Horizon sees it like any Xbox pad and uses Xbox-letter glyphs (the Deck's face buttons are A B X Y). Steam's default "Gamepad" layout works with no setup; the trackpads stay mouse.
- Started outside Steam on the Deck, the kernel's
hid-steamdriver exposes the controls as a gamepad too (while Steam isn't holding them), so it also works out of the box. - Text entry uses Steam's keyboard (Steam + X).
PR plan
The first round (input layer, menus, glyphs and options, rumble) shipped in PRs #120, #129, #145 and #149. The second round, each shippable alone:
- Focus navigation (this section above): the menu navigation option, focus targets and vanilla's arrow-key step, the focus highlight in both menu styles, the on-screen keyboard.
- Button prompts: contextual hints with the pad's glyphs in menus and optionally in game, and rebinding from them.
- DualSense output: adaptive triggers, lightbar, player LEDs and richer rumble over HID, off-safe.
- Launcher: the same focus navigation in the gpui launcher.
Each is tested with unit tests (focus steps, prompt contexts, report bytes), the injected pad and vscreen screenshots; each PR says what still needs a real controller.