Joystick Game Switcher

Starting with version 1.1, PinballY provides Javascript with fairly extensive access to the joysticks. We can use this to implement a feature that a few people have asked for, which is to use joysticks as command inputs.

I'm not talking about using joystick buttons for command inputs, which is something that PinballY has always supported natively. You can map joystick buttons to commands right in the settings dialog, under the Buttons section.

The new feature I'm talking about here is using the stick part of the joystick as a command input. For this example, we're going to use the stick as a game switcher: tilt it to the right to navigate to the next game on the game wheel, tilt it left to go to the previous game.

Getting access to the joystick device

Javascript gains access to the joystick devices via JoystickInfo objects, which you obtain from mainWindow.getJoystickInfo(). That method can be used in two ways: it can give you information on a particular joystick, such as the joystick that's the source of a joystick button event or axis event that you're currently handling; or, it can give you a list of all of the joysticks that PinballY knows about. When you're writing a script that's that sets up its own special-purpose joystick handling, it's that second use - getting all the joysticks - that you'll want to use.

let allJoysticks = mainWindow.getJoystickInfo();

Okay, we have a list of joysticks; now what? We need to pick the one that we're going to use for game-switcher input. If you only have one joystick device in your system, this is probably going to be as easy as picking the first one that getJoystickInfo() returned. The "list of all joysticks" is actually an array of JoystickInfo objects, so to pick the first, we just pull out element [0] from the array:

let joystick = allJoysticks[0];

But that's not very robust. If you ever add another joystick to your system, it might end up being listed in the array before your existing one, so that code could pick the wrong joystick. Another problem is that you might already have a couple of joystick-like devices in your system even if you don't think of them as actual joysticks. All of the common pin cab plunger controllers pretend to be joysticks, for example, because joysticks just fit in easily; Windows and Visual Pinball and everything else already knows how to access them.

Instead of just picking the first joystick in the list, then, we should somehow scan the list and pick out the one that we actually want. There are several ways to do this, because the JoystickInfo object gives you all sorts of information on each joystick that you can use to identify the particular one that you want to use for the game switcher function. For the purposes of this example, let's keep it really simple and just look for a joystick with a particular product name string. "Product name" is one of the basic attributes that joysticks report to Windows through the USB interface, and Windows passes it along to PinballY, which passes it along to Javascript through the JoystickInfo object. So we can scan the list and find the one that matches the name we're looking for:

let joystick = allJoysticks.find(j => /pinscape/i.test(j.productName));

That searches for the first joystick in the list that has "pinscape" somewhere in its product name. The "/.../" notation is a Javascript regular expression, which in this case is just a literal string we're searching for. The "i" after the second slash is for "insensitive to case", meaning that the search ignores upper/lower case differences and just matches either way.

I'm just using the Pinscape device as an example - you can use the same idea with any other joystick or gamepad device. Just change /pinscape/ to a similar substring that's unique to the device you're looking for. There's an easy way to determine the product name string for your joystick, by the way: it'll be listed in the Windows joystick control panel. Fire up the Windows Set up USB Game Controllers control panel (press Windows+R and type joy.cpl). The list of installed controllers that you see there is actually a list of the USB product name strings. Find your device in that list, and pick out a suitable substring to use in the regular expression search. I like looking for a substring rather than matching the whole name exactly, only because the name might change if you ever update the device's firmware. The trick is to pick a substring that's likely to remain the same in all future updates, but that's also unique enough that it won't accidentally match some other device in your system.

Letting the user select the joystick

A better way of selecting the joystick, albeit a more complicated way, is to let the user make the selection through the UI, and store it in the option settings file. That's outlined in a separate example, Joystick Selection Menu. You can combine the code from that example with the event handler here, if you want to use that technique for selecting the device.

Setting a value range

The USB specs allow joysticks to report their control axis positions on scales of their own choosing. The positions are always expressed as integer values, but the range - minimum to maximum - is up to the device to define. Windows passes the device's raw readings to PinballY, and PinballY passes them along to Javascript. That maintains the integrity of the data; there's no chance of losing any precision due to rescaling arithmetic. But it's a pain to deal with in Javascript, since we can't make any assumptions about the value range of the axes. "X" could be a number from 0 to 100, or it could be -100,000,000 to +100,000,000. Or just about anything else. If we want to know when the stick is halfway to the right, say, we don't know if we should look for an "X" value over 50 or over 50 million.

The JoystickInfo object provides you with the range of each axis, so you could use that to figure out the relative position. But there's an easier way: we can tell the JoystickInfo object to scale the raw reading from the device to a range of our choosing. The trade-off is that the rescaling can lose a little precision, so it's not the right choice for an application that needs the exact hardware readings. But for most applications, it's just fine, and it makes things a lot easier. For this example, let's put the X and Y axes on a fixed scale from -1000 to +1000:

joystick.setAxisRange("X", -1000, 1000); joystick.setAxisRange("Y", -1000, 1000);

After that, when we read the axis values from the X() and Y() methods, they'll be reported on our new scale. So the joystick is halfway right if X() >= 500.

Enabling axis change events

Now that we have our device, we have to enable it for axis change events. PinballY doesn't generate these events by default, out of a desire to minimize any performance impact. Joysticks can generate a lot of updates, and if there isn't any Javascript code interested in those update events, sending them to Javascript would just burn up CPU time for no good reason. So PinballY ignores joystick events by default, but it lets you enable them if you have a use for them.

(Don't worry that this is going to bog down your system horribly. The basic overhead isn't that much: it only takes about 50 microseconds to dispatch one of these events. Most joysticks send updates about every 10 milliseconds, so enabling events for one joystick adds about one half of 1% of overhead. Not so much that you'd notice. But the effect is additive across joysticks, so it's still good that any other joysticks in your system that aren't of interest to Javascript won't generate any added overhead.)

To enable joystick axis events, we go back to the JoystickInfo object that we selected for the joystick of interest, and we call the method enableAxisEvents(), telling it which axes we're interested in hearing about:

joystick.enableAxisEvents({ axis: ["X", "Y"] });

We listed "X" and "Y" as the axes of interest, so PinballY will only generate events when one of these axes changes. For standard joysticks, the "X" axis represents the left-to-right position of the main stick, and the "Y" axis represents the front-to-back position. So if we listen for "X" and "Y" changes, we'll get notified every time the basic 2D position of the main stick changes.

Listening for axis changes

Now that axis change events are enabled, we can set up an event listener to monitor the device. We'll get called whenever the joystick moves enough to register a different axis reading. For details on the axis change event, see JoystickAxisEvent.

mainWindow.on("joystickaxischange", ev => { if (ev.unit == joystick.unit) { // it's our joystick of interest - do something here! } });

We start by checking that the joystick that generated the event is the same one that we're monitoring. This might seem pointless, since we've only just enabled events for the one joystick, but this could become important if you add more modules later that might monitor other joysticks. We match the joystick in this case by checking the unit property in the event against the unit property in our JoystickInfo object. Those properties contain the "logical unit numbers" of the respective joysticks. The logical unit number is an internal ID that PinballY assigns to each joystick device for the duration of this program session, and it's designed for exactly this kind of situation. If the unit number in the event matches the unit number in our JoystickInfo object, we know that we're talking about the same actual joystick device. (The unit number doesn't mean anything to Windows, or to any other programs - it's purely internal to PinballY.)

How are we going to use these events to trigger game switching? Well, the first thing we have to do is pick a threshold for how far the stick has to move before we count it as a Next/Previous gesture. That's entirely a matter of taste, so you'll undoubtedly want to fine-tune this to your liking, but for now let's just say 1/4 deflection to the left or to the right is enough to trigger a change. Remember that we already adjusted the range that the axis values will report to -1000 to +1000, so 1/4 deflection to the left will be X() <= -250, and 1/4 to the right will be X() >= 250.

if (joystick.X() <= -250) { // moving left } else if (joystick.X() >= 250) { // moving right }

In practice, I've found that joysticks can be a little "noisy" around a threshold like this - they're analog devices, after all. I think it's better to build in a little hysteresis: you have to move a little past the threshold before the joystick command kicks in, and you have to move a little further the other direction before it shuts off. Here's how we can add 25 units of hysteresis, to make the transitions a little smoother:

let x = joystick.X(), r = Math.abs(x); if (previouslyOff && x > 275) { // turn on } else if (previouslyOn && x < 225) { // turn off }

Now you have to move just a little past the threshold (25 extra units) in each direction before the command will take effect, and then it'll stay in effect until you move a little past the threshold in the opposite direction.

Switching the game

To perform the game switch, we'll simply simulate a button press on the Next or Previous button, using mainWindow.doButtonCommand:

mainWindow.doButtonCommand("Next", true, 0); // navigate right mainWindow.doButtonCommand("Prev", true, 0); // navigate left

Auto-repeat

When you use mainWindow.doButtonCommand() to send one of the wheel navigation commands, the command will automatically repeat until you call doButtonCommand() again to turn the button "off", by calling it with false in the down argument slot (the second argument). This makes it really important that we continue to monitor the joystick after ending the initial command, so that we can PinballY to stop repeating the command once the joystick is no longer deflected enough to trigger it.

Here's the strategy we're going to use in the command handler to accomplish this:

The nice thing about this approach is that it leaves the details of the repeat timing to PinballY. The rate of the joystick events won't affect the wheel spin rate, since we only have to pay attention to when the wheel spin starts and stops. As long as the joystick is deflected far enough to the left or right to trigger the command, we just let PinballY handle spinning the wheel; we only have to get involved again when the joystick returns to the center position, at which point we stop the wheel spin.

Fine-tuning the repeat rate

PinballY's wheel spin auto-repeat rate is designed around the idea that it's going to be triggered by button presses. Most people expect that to act the same way as a keyboard key does when you press it and hold it, so the default wheel repeat rate is the same as the Windows keyboard repeat rate. You can customize that rate in the settings (see Game Wheel Options), but even so, the joystick can benefit from some special treatment. A button is just an on-off switch, but a joystick is analog - it doesn't just go left and right, but goes left and right by continuously varying degrees. To me, it would feel natural in this context to use the amount of deflection to control the speed: move it just a little to the left right for slow wheel spin, all the way to the left or right for really fast spin, and smoothly varying speeds in between.

We can accomplish this via mainWindow.setWheelAutoRepeatRate(), which lets us set the "instantaneous" repeat rate during a wheel spin command. Since we're already monitoring the joystick position via our event handler, we can simply add a call to this method from our event handler, setting a new speed based on the latest joystick position.

setWheelAutoRepeatRate sets the rate in milliseconds, so we just need to choose a range of repeat times for various degrees of joystick deflection:

let x = js.X(), r = Math.abs(x); mainWindow.setWheelAutoRepeatRate( r > 850 ? 100 : r > 750 ? 200 : r > 500 ? 300 : 500);

You can adjust that to add as many gradations of speed as you like, and to speed up or slow down the extremes to fit what feels most comfortable to you.

The full event handler

Putting all of those ideas together, here's what our full event handler looks like.

let oldCommand; mainWindow.on("joystickaxischange", ev => { if (ev.unit == js.unit) { // check for crossing the threshold at 250, with hysteresis let x = js.X(), r = Math.abs(x); let newCommand = oldCommand; if (!oldCommand && r > 275) newCommand = (x > 0 ? "Next" : "Prev"); else if (oldCommand && r < 225) newCommand = undefined; // if the command changed, turn the button commands on/off if (newCommand !== oldCommand) { // turn off the old command button, if any if (oldCommand) mainWindow.doButtonCommand(oldCommand, false, 0); // turn on the new command button, if any if (newCommand) mainWindow.doButtonCommand(newCommand, true, 0); // remember the new command for next time oldCommand = newCommand; } // update the auto-repeat speed, according to how far the // joystick is deflected if (newCommand) mainWindow.setWheelAutoRepeatRate( r > 850 ? 100 : r > 750 ? 200 : r > 500 ? 300 : 500); } });