Posted by JibbSmart on 02 Jun 2019 14:20
Let's look at what we can do with the right stick in 3D games now that precise aiming is handled by the gyro (as described in Part 1). I propose that in 3D games we start using the flick stick. Flick stick maps the angle of one of the thumbsticks (in the following examples, the right stick) to the same angle turn in-game. This gives the player far more direct and immediate control over their bearing than traditional stick controls. In fact, with flick stick, I believe a controller is now better than a mouse for big turns, at least for the average player:
A quick explanation of flick stick using JoyShockMapper and DOOM.
One of my main motivations for making JoyShockMapper was to see if something like this would make camera control in games better. I figured it'd make up for the shortcomings of the gyro in range — have the flick stick cover big movements while the gyro covers anything that requires precision. Perhaps it'd be useful with practice for high-level players as something like a "pro mode". Once implemented, however, it exceeded my expectations. It's not just for those willing to put in a lot of practice. It's both easy to pick up for the first time and extremely useful once you've spent some time with it. I'll get more into learning how to use it later. For now, let's just get right into how it works.
There are two steps to flick stick, and they both map a real world angle on the right stick to the same in-game angle:
- Flicking
- Turning
For JoyShockMapper to trick other games into having flick stick requires some calibration (how far does the mouse need to move to turn a given angle?) but once you're set up, it's great. If you're implementing this in your game, you don't have to fake it with mouse moves, so there'll be no calibrating required.
Let's go.
Flicking
The flick is what happens when the flick stick is first tilted. The player indicates an angle they want to turn to relative to their current orientation, and the camera turns that angle in a quick, smooth flick.
Because we want the flick to be a deliberate movement from the player and because we can get a more precise angle for the flick, we use a really big deadzone to decide whether the stick has been tilted. In JoyShockMapper, by default, the flick won't occur until the stick is 90% of the way from the centre to the outer edge.
Here's what happens when we flick:
So, when we tilt the stick, the angle of the stick is calculated, and over a very short window of time that angle is added to the player's yaw. That's it. Crucially, flick stick does not calculate an absolute in-game angle to move to, because the flick stick should not stop the player from making adjustments with the gyro or any other means of turning.
Here's roughly how I do it in JoyShockMapper (look up handleFlickStick if you have the source, but I'm going to simplify things here).
// don't want a lot of state, but flick happens over time, so it's necessary float flickProgress = 0.0; float flickSize = 0.0; // settings float FlickThreshold = 0.9; float FlickTime = 0.1; // this will return a yaw change for the player // that'll be added to other input (such as gyro input) float HandleFlickStick(Vec2 lastStick, Vec2 stick, float deltaTime) { float result = 0.0; float lastLength = Length(lastStick); float length = Length(stick); // by comparing the last frame to this one we can decide whether a flick is starting if (length >= FlickThreshold) { if (lastLength < FlickThreshold) { // flick start! flickProgress = 0.0; // restart flick timer // stick angle from up/forward flickSize = Atan2(-stick.X, stick.Y); } else { // turn! // .. } } else { // turn cleanup // .. } // continue flick float lastFlickProgress = flickProgress; if (lastFlickProgress < FlickTime) { flickProgress = Min(flickProgress + deltaTime, FlickTime); // get last time and this time in 0-1 completion range float lastPerOne = lastFlickProgress / FlickTime; float thisPerOne = flickProgress / FlickTime; // our WarpEaseOut function stays within the 0-1 range but pushes it all closer to 1 float warpedLastPerOne = WarpEaseOut(lastPerOne); float warpedThisPerOne = WarpEaseOut(thisPerOne); // now use the difference between last frame/sample and this frame/sample result += (warpedThisPerOne - warpedLastPerOne) * flickSize; } return result; }
Okay, I know that's a lot. Let's walk through it. Right at the top, outside the function, we have two variables we'll need to remember to complete a flick once it has started, and two settings that determine how we flick: a threshold for how far the stick needs to be pushed to trigger a flick (FlickThreshold), and the amount of time we want a flick to take (FlickTime). I've set FlickTime to the default in JoyShockMapper, 0.1 seconds.
Then we get to the function itself. We have the previous frame's stick info. We start by checking if the current frame's stick is beyond the flick stick threshold. If it is, we use the previous frame's stick info to discern between two options. Either:
- this is the first frame beyond the threshold, and we're starting a flick, or
- we have already flicked before without releasing the stick, so this is a turn (explained further down).
By comparing this stick state to the previous stick state, we can figure out if we need to execute a flick or a turn without holding onto any extra state. It might be worth having a little extra state so we can pad the flick threshold and avoid accidental flicks if players are keeping the stick around the flick threshold for whatever reason. I'll leave that to you.
Now we've figured out whether we need to execute the flick action. If we've started a flick, we need to remember how big of an angle change we're going to make so that we can complete that angle change over the next few frames. We do that by resetting flickProgress, which tracks how far through a flick we are (in seconds), and setting flickSize to the angle the stick is making (from up/forward being 0°).
Then, regardless of the current stick position, there may be a flick to complete, so we have the "continue flick" section at the end of the function. We accumulate time since the flick began in flickProgress so we can compare it to FlickTime. If the last frame was before FlickTime has been reached, we continue our flick. We compare the last frame's progress through the flick and this frame's progress through the flick (in time), and use those to figure out what portion of the flick angle we want to add to the player's yaw this frame.
Perhaps the least clear part is the warping part. In animation or in real life, movements are very rarely perfectly linear. Movements will start slowly, accelerate to full speed ("easing in"), and then decelerate to a stop ("easing out") at the end of the movement. So for the flick, it's probably best not to complete the movement linearly — it'll feel robotic and unnatural.
Having said that, the warping we do above starts fast and decelerates to a stop. There's no accelerating at the beginning — there's no "easing in". In games, for animations that are responding to player input, it's fairly common to forego easing in for the sake of making the game feel more responsive.
So here's our warping function. It's really simple.
float WarpEaseOut(float input) { float flipped = 1.0 - input; return 1.0 - flipped * flipped; }
An input of 0 maps to 0, 1 maps to 1, but everything in-between gets pushed closer to 1. You can replace it with any function you want — something like smoothstep is good if you want to ease in and ease out. I recommend avoiding easing in just so it feels more responsive. However, as a player, I personally probably won't be bothered how you do it as long as the FlickTime is nice and quick.
Below you can see a side-by-side of our ease out function (left), linear (middle), and smoothstep (right), which eases in and out. They're all completing the same 135° flick in 0.1s, and they all start the movement at the same time and finish at the same time:
Look really closely, and you'll see that while they all complete the flick at the same time, the left one feels snappiest, as it covers the most ground at the beginning. Now, at this speed, they're probably all acceptable. The differences aren't obvious until we slow the recording down. But if you have the option for a slower flick, the warping function becomes more important.
What's a good FlickTime?
I would say around 0.1s is a good flick time. 0.2s makes for a smooth turn, but feels slow enough that I feel like I'm waiting for it (even though it's still probably faster than my reaction time). So I would recommend less than that, and I've found 0.1s to work really well for me. It feels snappy and deliberate.
The obvious question from here is: why take any time at all? JoyShockMapper does it for 3 reasons, the first two of which could be considerations for your game:
- Aesthetics - A quick, smooth turn looks more natural than a sudden reorientation.
- Player bearings - The smooth transition may help players and spectators maintain their sense of the camera's bearing in the world.
- Don't look like cheating - This one only applies to JoyShockMapper, because it's used to play other games that don't have flick stick. Spectators or those watching a kill-cam may see the unnaturally sudden reorientation as an indicator that the player is cheating. Using JoyShockMapper is not cheating — it has no awareness of the state of the game, and only does exactly what the player tells it to. But it's probably a good idea to avoid being accused of cheating, too.
Turning
The turn is what happens when the player rotates their stick while it is tilted. The difference in angle from the previous frame's stick position is added to the player's yaw pretty much instantly. This might be to make small adjustments to their flick, to follow a target that's moving around them, to watch a corner as they turn it in case an enemy is waiting, or even to pull off circle jumping in something like Quake.
It is generally not added over a window of time like the flick is, but instead added right away (with a caveat we'll get to later). This is because it is mimicking a turn the player is actually making with their thumb. Like mouse and gyro aiming, this maps a real-world displacement to an in-game displacement, and it's best to be as responsive and direct as reasonably possible.
This is what that looks like:
Let's look at some code. This is the same flick stick function from above, but now we're omitting flick-specific sections and filling in the turn sections.
// settings float TurnSmoothThreshold = 0.1; // this will return a yaw change for the player // that'll be added to other input (such as gyro input) float HandleFlickStick(Vec2 lastStick, Vec2 stick, float deltaTime) { float result = 0.0; float lastLength = Length(lastStick); float length = Length(stick); // by comparing the last frame to this one we can decide whether a flick is starting if (length >= FlickThreshold) { if (lastLength < FlickThreshold) { // flick start! // .. } else { // turn! // stick angle from up/forward float stickAngle = Atan2(-stick.X, stick.Y); float lastStickAngle = Atan2(-lastStick.X, lastStick.Y); float angleChange = Wrap(stickAngle - lastStickAngle, -PI, PI); result += GetTieredSmoothedStickRotation(angleChange, TurnSmoothThreshold / 2.0, TurnSmoothThreshold); } } else { // turn cleanup if (lastLength >= FlickThreshold) { // we've just transitioned from flick/turn to no flick, so clean up ZeroTurnSmoothing(); } } // continue flick // .. return result; }
At its core, all we're doing is getting the difference in stick angle from last frame to this frame and adding it to the camera yaw. An angle to an angle — an immediate response to the player's input. To that end, we have 3 functions that need explaining:
- Wrap
- GetTieredSmoothedStickRotation
- ZeroTurnSmoothing
Wrap takes a number and an interval (inclusive at the bottom, exclusive at the top), and returns that number wrapped to that interval. So Wrap(1, 0, 10) would return 1, Wrap(11, 0, 10) would return 1, and Wrap(-1, 0, 10) would return 9. In this case, we're wrapping to the [-PI, PI) interval (replace PI with 180 if you're using degrees instead of radians), and that just means that we're assuming the stick took the shortest path possible from the last position to this position. I'll leave the implementation to the reader. However, if you ever turn an object to face another, there's a good chance you have a function like this somewhere already.
The other two functions, GetTieredSmoothedStickRotation and ZeroTurnSmoothing, are necessary because sometimes we need just a little smoothing:
Just a little smoothing
Controller thumbsticks might not have the resolution to accurately express the player's intent with something like flick stick. Specifically, the DualShock 4 has the entire -1.0 to +1.0 range of their sticks represented by one byte per axis. This appears to be plenty for regular aiming, as player thumbs usually aren't any more precise than that for a single action. But, like top-down twin-stick aiming, flick stick allows the player to make very small adjustments to their aim, and with the DS4 that results in obvious and unappealing steps when fine-tuning.
The solution here is to use soft tiered smoothing, just like we talked about in part 1. I would call it crucial that smoothing only applies to small movements, and soft tiered smoothing as described there satisfies that condition while still honouring the stick's displacement once any smoothing is done. Here's the difference it makes to small movements, where the stick's limited resolution can become a problem:
So let's get to the code for GetTieredSmoothedStick:
float GetDirectStickRotation(float input) { return input; } // smoothing buffer float[] InputBuffer; int CurrentInputIndex; float GetSmoothedStickRotation(float input) { CurrentInputIndex = (CurrentInputIndex + 1) % InputBuffer.Length; InputBuffer[CurrentInputIndex] = input; float average = 0.0; foreach (float sample in InputBuffer) { average += sample; } average /= InputBuffer.Length; return average; } float GetTieredSmoothedStickRotation(float input, float threshold1, float threshold2) { float inputMagnitude = Abs(input); float directWeight = (inputMagnitude - threshold1) / (threshold2 - threshold1); directWeight = Clamp(directWeight, 0.0, 1.0); return GetDirectStickRotation(input * directWeight) + GetSmoothedStickRotation(input * (1.0 - directWeight)); }
This is the same soft tiered smoothing we used in Part 1, and just like before, I'll save an in-depth explanation of why everything works the way it does for its own post, but here's what we get out of doing it this way:
- No smoothing at all is applied to inputs greater than a small threshold, and
- No matter what that threshold is, the smoothed and raw inputs are combined in such a way that the final displacement is as if no input was smoothed at all.
Most inputs don't need any smoothing, so don't apply any smoothing to most inputs. And when we do apply smoothing, we don't want that to mess with the total displacement.
Finally, when the stick is no longer tilted, we zero out the smoothing buffer so what's in there doesn't affect future turns:
ZeroTurnSmoothing() { for (i in 0..InputBuffer.Length) { InputBuffer[i] = 0.0; } }
This means the final displacement may not quite honour the exact stick position if its last movement before the player released the stick is still being smoothed out. However, in this case I think it's preferable to stop turning as soon as the stick is released rather than completing a small smoothed turn when the player has already released the stick.
And that's all there is to it! With practice, players can flick and turn their right stick to look in any direction, responding immediately to threats, checking corners, following tight paths, and tracking fast-moving targets.
Learning to play with Flick Stick
Like any new kind of control, flick stick won't be second nature right away. However, from my own experience and from showing others, I expect you'll find flick stick pretty useful almost right away.
Anecdotally, it appears to be typical for new players to just use the flick stick for big approximate left and right turns without much concern for the angle they actually want to turn to: tap right on the stick to turn right, and tap it again if that wasn't far enough. With a little practice, players become comfortable using the flick stick to quickly flick in a desired direction fairly precisely, and start relying on turning the flick stick for much of their navigation, perhaps to the detriment of their aim. Finally, players turn back to gyro for most turning when they're expecting an encounter at any second, but still fall back on flick stick for big flicks, big turns, and general navigation when not expecting a fight.
Flick stick is still very new, though. I've not seen it implemented in a game (EDIT 2022: flick stick features in Boomerang X, CS:GO, and Fortnite, with more on the way), but you can try it in just about any PC game you want using JoyShockMapper with a little calibration. I'd love to hear how more players find their first experiences with flick stick. And as players spend more time with it than I have, or just have a better talent for games than I do, I can't wait to see what "expert" flick stick play will look like (EDIT 2022: just one example of an expert flick stick player)!
Coming soon
As with the gyro controls in Part 1, flick stick should be simple enough to patch into an already published game on the PS4 or Switch. But we may have hit the limit of what we can try out by just faking mouse movements with a tool like JoyShockMapper.
In Part 3 we'll go beyond using the gyro as just a mouse. Why lock the aimer to the centre of the camera when we have a gyro and a right stick? I'll have to find other ways to demonstrate these, but in my experience prototyping these controls, I reckon they'll be game-changing.
Keep an eye out for part 3 when I finish writing it.