VIPER 11 · NotesFly it →

Note 7 of 15

Flying with a real stick

Flight sticks, throttles and pedals through the Gamepad API, and why the mouse now turns your head.

Most people fly VIPER 11 with a keyboard, a few with a game pad, and more than you would think on a phone. But some players have a real flight stick on the desk, a throttle under the left hand and rudder pedals on the floor, bought for flight simulators that cost more than this whole game. A browser can read all of that. It just doesn’t make it easy.

How a browser sees a joystick

Web pages read controllers through the Gamepad API. There are no events for a stick moving: the page calls navigator.getGamepads() once a frame and gets back a list of devices, each with an id string, an array of axes from −1 to 1, and an array of buttons. The game reads them at the top of every frame, before the flight model steps.

The API knows exactly one controller shape. If the browser recognises a pad as an Xbox or PlayStation layout, it reports mapping: "standard" and the numbers mean the same thing everywhere: axes 0 and 1 are the left stick, button 7 is the right trigger, and so on. A flight stick is not that shape, so it arrives with mapping: "" and its raw HID report: whatever axes and buttons the device describes, in whatever order. On top of that, browsers deliberately hide controllers until one is used, so a page can’t fingerprint you by the devices plugged in. Until you press a button on it, your stick doesn’t exist.

Figure 1 · Same stick, different numbers

A Thrustmaster T.16000M seen by the Gamepad API. The device reports four axes and a hat switch as HID “usages” (X, Y, Rz, Slider, the hat). Chrome gives each usage a fixed slot, so the twist lands on axis 5, the throttle wheel on axis 6 and the hat on axis 9, with empty slots between. Firefox packs the same axes in order. The standard mapping at the top is the one layout the API defines, and a stick never gets it.

The hat switch is its own puzzle. It’s an eight-way thumb switch, and many browsers report it as a single axis that jumps between eight values, from −1 for up, clockwise in steps of 2/7, to 1 for up-left. Centred, it reads about 1.29, which is outside the axis range entirely. That odd resting value turns out to be useful: an axis that sits at 1.29 when nothing is touched is almost certainly a hat.

Finding the stick, the throttle and the pedals

The game splits controllers into two paths. A device with the standard mapping goes down the game-pad path, which hasn’t changed: left stick flies, triggers run the throttle. Everything else, unless its name gives it away as a game pad in disguise, goes down the joystick path.

There, each device is identified from its id. Chrome writes “T.16000M (Vendor: 044f Product: b10a)” and Firefox writes “44f-b10a-T.16000M”, so the vendor and product numbers can be pulled out of either. Those pick a profile: the device’s role (stick, throttle, pedals, or a stick and throttle in one), which HID usages it has, and which ones fly what. Because a profile names usages rather than indices, the same profile works in both layouts of Figure 1. If the device reports exactly as many axes as the profile lists, it’s packed; otherwise it’s laid out by usage.

A device with no profile is guessed from its name and its shape. “Pedals” or “rudder” in the name, or three axes and no buttons, means pedals: two toe brakes and a rudder. “Throttle” or “quadrant” means a throttle on its first axis. Anything else is a stick, with X and Y for roll and pitch, a twist if there is one, and a slider if there is one.

Table · The defaults

DeviceRoleAxes (Chrome)Buttons
Thrustmaster T.16000Mstickroll 0 · pitch 1 · twist 5 · throttle wheel 6 · hat 92 brakes · 3 gear · 4 speed brake · 5 ident · 6 time of day · 7 RTB · 8 pause
Logitech Extreme 3D Pro, TCA Sidestickstickroll 0 · pitch 1 · twist 5 · slider 6 · hat 9
Thrustmaster TWCS, TCA Quadrantthrottlethrottle 2 (TWCS) · 0 (TCA)1 speed brake · 2 gear · 3 ident · 4 time of day · 5 RTB
HOTAS Warthog throttlethrottlethrottle 2 (Z)
X56 / X55 throttle, Virpil throttlesthrottlethrottle 0
HOTAS Warthog stickstickroll 0 · pitch 1 · hat 94 brakes (paddle) · 2 gear · 3 speed brake
Saitek / Logitech X52, X52 Prostick + throttleroll 0 · pitch 1 · throttle 2 · twist 5 · hat 9as the T.16000M
X56 / X55 stick, VKB Gladiator, Virpilstickroll 0 · pitch 1 · twist 5 · hat 9as the T.16000M
Saitek Pro Flight pedals, Thrustmaster TFRP / T-Rudder, MFG Crosswindpedalstoe brakes 0 + 1 · rudder 5—
The layouts the game starts with, numbered as the calibration page numbers them (buttons from 1, as printed on most sticks; axes from 0, as the API counts). They are typical layouts, not guarantees: drivers, vendor software and browsers all move things around, which is why every one of them can be changed. The trigger is left free on purpose: in the menus it selects.

Several devices at once merge into one set of controls. Each action looks for its best source among everything connected: pedals win the rudder over a stick’s twist, a separate throttle wins over the wheel on a stick’s base, and the stick keeps pitch, roll and the hat. Unplug the pedals mid-flight and the rudder falls back to the twist on the next frame; plug them back in and it returns to them. Anything you bound yourself takes priority over all of this, as long as that device is connected.

Calibration

Settings, Controls, Joystick lists every action with what it’s bound to. Pick one and the page waits for you: it takes a snapshot of every axis and button on every device, then watches for the first new button press, or the axis that has moved furthest from its snapshot, past half its travel. Asking for a particular motion (“pull the stick back”, “push the throttle forward”) means the direction you moved also settles which way round the axis reads, so you rarely need the invert switch. Esc gives up, and so does ten seconds of nothing.

The joystick calibration page: a list of actions and their bindings on the left, a live monitor of three devices' axes and buttons on the right
The calibration page with a T.16000M, a TWCS throttle and Saitek pedals. On the right, a bar per axis and a dot per button, live, each labelled with the action it drives; the hat is the small rose. The shaded band on a bound axis is its dead zone. The binding, invert and dead zone are saved for the next visit.

Dead zones and expo

A real stick never quite centres. A worn spring, a cheap potentiometer or a hand resting on the grip leaves it a few percent off, and a jet that reads a constant 3% of roll will slowly bank on its own. So each axis has a dead zone: inside it the stick reads zero, and outside it the remaining travel is stretched back out to full, so the stops still mean full deflection. The default is 4% for pitch and roll and 8% for a twist grip, whose springs are weaker.

After that comes expo. The game raises the stick’s deflection to the power 1.6, then scales it by the stick sensitivity setting (0.9 out of the box). Half deflection gives about a third of the authority, which leaves plenty of fine control around the centre for holding a climb or lining up on final, while the last bit of travel still reaches the full 9 g pull. The fly-by-wire then turns that into a rate or a G, which is a different story, told in the flight note.

Figure 2 · From your hand to the flight computer

The joystick path in the game (src/input/joystick.ts and input.ts): the raw axis on the bottom, what the flight computer receives up the side. The dashed line is a straight, linear stick for comparison. Move the pointer across the chart to trace a deflection; if you have a controller connected and you press one of its buttons, its first axis drives the gold dot instead.

The throttle and its detent

A keyboard or a trigger moves the throttle at a rate: hold T and the lever creeps forward. A throttle axis is different. It is a position, and the lever simply goes where it is. That raised a small problem: the game itself sometimes sets the lever, at the start of a session or when the autopilot takes you home, and a throttle resting at idle would drag it straight back. So the throttle axis only takes over once you move it. Until then it waits.

The F-16’s throttle has a detent at MIL, full power without the afterburner. Past it the afterburner lights. On the keyboard the game imitates that with a gate that stops the lever at MIL. On a real throttle the detent is a place on the travel: by default 80%, which you can move to match a physical bump on your own throttle. Around it the lever holds at MIL across a small plateau, so you can find it by feel. Below it the travel maps evenly from idle to MIL; above it, from MIL to full afterburner.

Figure 3 · Where MIL sits

The throttle’s travel against the game’s lever, with the default 2% dead band at each end. The flat step at the detent is MIL; the gold segment above it is afterburner. Drag the detent to where your own throttle’s bump is.

Your controller, live

Figure 4 · What this page can see

No controller yet. Connect one and press any button on it: browsers only reveal a controller once it has been used.

The raw Gamepad API in your browser, read every frame by this page, with what the game would make of each device: its mapping, its profile or a guess, and the role it would play.

Why the mouse looks, and Shift flies

The same work changed the mouse. It used to be a virtual stick: move it and the jet rolled and pitched, let it rest and the stick sprang back to centre. It worked, but a mouse has no centre to return to and no spring to push against, so the stick only ever knew how fast you were moving, not where you were holding. Flying it meant constant small motions, and looking around needed a right-drag.

Now the mouse is your head. Click once and the game captures the pointer, and from then on the mouse turns the pilot’s head as it would in any first-person game, with a little inertia so the view swings rather than snaps. Stop moving it and after two seconds the head eases back to the GoPro framing, so you can let go of the mouse and fly with the keys without the view drifting. Hold Shift and the mouse becomes the old stick again; let go and the stick recentres while the fly-by-wire holds the attitude you set, which is what makes a short, deliberate input with the mouse enough.

Ctrl and Cmd work as well as Shift, with one caveat. A page can’t block the browser’s own shortcuts, so holding Ctrl or Cmd while you press W (pitch down) will close the tab. Shift is the one that is always safe. And if you liked the old way, Settings, Controls, Mouse swaps them: the mouse flies and Shift looks around.

Figure 5 · Look, then fly

move the pointer over the view · hold Shift to fly
The game’s head-look and mouse-stick code, run here on a toy horizon. Moving over the view turns the head (the frame is the canopy); holding Shift, or switching the mode, turns the same movement into stick deflection, shown in the ring, and the horizon banks. Rest for two seconds and the head comes back to centre. On a touch screen, drag, and use the buttons to switch.

What couldn’t be tested

All of this was built and tested against simulated devices: the test suite replaces navigator.getGamepads with a stick, a throttle and pedals that report exactly as Chrome would, and drives them axis by axis. The default layouts in the table come from how these devices describe themselves over HID and how Chrome lays that out, not from each one on a desk. Vendor software that remaps axes, Linux, Safari and Firefox’s own hat handling will all differ somewhere. That is what the calibration page is for, and why everything on it is saved.

Fly it

viperoneone.com

VIPER 11 is a browser flight game: three.js, WebGL2 and Web Audio. Every figure on this page is drawn from the game’s own code and numbers. Made by apek. Follow me @apekshik for what’s next.