Input: Fingers, Keys, and Sticks
Your game will be played on a laptop keyboard, a phone screen, and, if you are lucky, a couch controller. Mar treats all three as first-class citizens with the same architecture: input arrives as messages, and you translate device events into game intent. This chapter is the full inventory, then the pattern that keeps three control schemes from becoming three games.
Keyboard
Two subscriptions deliver the stream:
subscriptions model =
Sub.batch
[ Keyboard.onKeyDown KeyDown -- KeyDown : Keyboard.Key -> Msg
, Keyboard.onKeyUp KeyUp
]
Keyboard.Key is a builtin union whose constructors are qualified, named by physical position (the W3C code standard): Keyboard.ArrowLeft, Keyboard.KeyW, Keyboard.Space, Keyboard.Enter, Keyboard.Escape, Keyboard.ShiftLeft, Keyboard.Digit1, and so on. Position-based naming means WASD stays WASD-shaped on an AZERTY keyboard, which is what a game wants.
The one idea that matters: events are edges, movement wants levels. Chapter 2’s pair of booleans is the standard converter, and it generalizes to a held-keys record. The complementary rule: actions want edges. A jump should fire on the key-down message, once, not “while held” (or the player hovers up the whole level). Down-edge for verbs, held-level for steering; most input bugs are one of these two applied backward.
Support both arrows and WASD, always; it costs four lines of case and spares every left-handed player.
Gamepad
Gamepad deliberately mirrors Keyboard:
Sub.batch
[ Gamepad.onButtonDown PadDown -- PadDown : Gamepad.Button -> Msg
, Gamepad.onButtonUp PadUp
, Gamepad.onStick PadStick -- PadStick : Int -> Int -> Msg
]
Gamepad.Button covers the standard layout: Gamepad.A, Gamepad.B, Gamepad.X, Gamepad.Y, the d-pad as Gamepad.Up / Down / Left / Right, shoulders Gamepad.L1 / R1, and Gamepad.Start. The stick reports both axes as integers in −100..100, already dead-zoned (anything under 25% of throw arrives as 0), so a resting stick is exactly (0, 0) and you never write drift-filtering code. Gamepad.onRightStick is there for dual-stick games; the Norse FPS uses left to move, right to turn.
Because buttons and keys are both “edge messages carrying a union”, the natural move is to funnel them into the same handlers: the d-pad cases sit right beside the arrow-key cases and set the same booleans.
Touch
Touch (and the mouse; they share the pointer) comes through canvas attributes rather than subscriptions, because coordinates only mean something on the canvas:
canvas Pixelated
[ onTap Tapped -- Tapped : Int -> Int -> Msg, canvas-local x y
, onDrag Dragged -- pointer moved while down
, onRelease Released -- pointer lifted
, onAltTap AltTapped -- right-click / two-finger tap (desktop niceties)
, onWheel Wheeled -- scroll deltas, dx dy
, onHover Hovered -- desktop only; never gameplay (see below)
]
shapes
There are no widgets on a canvas, so touch controls are shapes you draw plus arithmetic you own: a tap is a point, your buttons are rectangles, and update decides what was pressed. That sounds like a chore and is actually freedom; virtual controls can be anything. The shipped games use three schemes, in increasing order of effort:
- Direct manipulation (Star Catch, the party duel): the finger is the controller: drag the tray, tap the zone. Best when the game’s verbs map onto positions.
- Virtual buttons: draw fire and jump zones in the corners, hit-test taps against them, track held state from tap and release messages.
- Virtual pads: the FPS draws two thumb areas; a drag inside one is converted to a stick vector (distance from the anchor, clamped to the −100..100 scale, sharing the gamepad code path).
Reserve onHover for desktop garnish (tooltips, cursor highlights). Hover does not exist on touch hardware, and a mechanic that needs it will silently not exist on phones.
Device: choosing the scheme with facts
The wrong way to pick a control scheme is user-agent sniffing (iPads lie). The right way is Device, a live subscription of actual capabilities:
type Msg = DeviceChanged Device | ...
subscriptions model =
Sub.batch [ Device.watch DeviceChanged, ... ]
The record answers with axes, not identities: pointer (Coarse or Fine), anyFine, supportsHover, viewport width/height, prefersDark, prefersReducedMotion. Two derived helpers settle the common cases: Device.touchOnly d (“show the virtual pads”) and Device.canHover d (“hover garnish is allowed”). It fires immediately with the current state and again on every change, so plugging a trackpad into an iPad mid-game genuinely switches your scheme if you wrote it to.
The examples’ convention, worth copying:
showPads : Model -> Bool
showPads model =
case model.dev of
Just d -> Device.touchOnly d
Nothing -> False
And let it choose your copy too: the title screen that says “press Space” on desktops and “tap to start” on phones is a one-liner off the same record.
The intent layer
Here is the pattern that keeps three devices from tripling your logic. Devices produce device messages; update immediately translates them into intents; the simulation only ever sees intents.
-- device layer: many sources, one vocabulary
KeyDown Keyboard.ArrowLeft -> press DLeft model
PadDown Gamepad.Left -> press DLeft model
Dragged x _ -> steerToward x model -- touch expresses intent directly
-- intent layer: one implementation
press : Dir -> Model -> (Model, Cmd Msg)
The simulation code never asks “was this a key or a button?”, the tutorial text is the only place that cares which devices exist, and adding a fourth input source (a second stick, a MIDI pedal, whatever arrives someday) is new cases in the translation layer and zero changes below it. Every multi-scheme example game converges on this shape; start there and skip the intermediate suffering.