Hello everyone!
In my last post about Operation KREEP, I mentioned that for the 1.2 update of the game I made some improvements to the input handling logic and hinted a near future deep-dive into this topic. Quite a while ago, right before releasing the Steam version, I wrote a similar post describing the input handling enhancements I made back than. Although it is a bit lengthy, if you are interested in the technical details of high level input handling logic I highly recommend it. Not a requirement though, since I'm continuing this post with its summary to level up your knowledge for easier digesting of the upcoming technical details.
Short recap
The game plays on a grid and all entities move complete tiles (no standing in between two tiles). Each "move" action by a player will actually take multiple frames to complete (precisely 12 which is 200 milliseconds under 60 fps). The players usually do not feel this (it does not feel laggy/bugging), since it is a quite fast and action packed game + 200 ms is not much and the overall rules/design of the game is deeply intertwined with grid based movement.
The initial movement handling logic was utterly simplistic. If a direction button is pressed the player moves towards that direction, with a silly hard coded priority for handling cases when multiple direction buttons are down: "Up" beats "Down" beats "Left" beats "Right". When a player is already moving and the corresponding direction button is held down it will be handled with highest priority, so continuing movement forward is considered "important/intentional".
Warning, warning incoming pseudo code:
void handleIdle() {
if input.isPressed("up") {
startMovement("up");
} else if input.isPressed("down") {
startMovement("down");
} else if input.isPressed("left") {
startMovement("left");
} else if input.isPressed("right") {
startMovement("right");
}
}
First pass of input handling in "Idle" character state.
void handleMoving() {
if (input.isPressed(currentDirection)) {
continueMovement();
} else if (input.nonePressed) {
stopMovement();
} else {
// this will handle direction change
// the same way as in "Idle" state
handleIdle();
}
}
First pass of input handling in "Moving" character state.
That is it. This simple control mechanism was really easy to code certainly but it wasn't intuitive nor responsive, and clearly intentional actions were missed out from time to time. It took me some time to realize that it was bugging many players and it could be improved a lot.
Around the 1.1 (Steam) release, I made significant changes to this system, by introducing some smart checks to figure out the intentions of a player as best as possible. These rules included:
- Checking the surroundings of the player character.
- Taking non-walkable target tiles into consideration (making them a less preferred choice).
- Taking dynamic blockers like other players, props or the KREEP, into consideration (just as important targets as walkable tiles).
- Saving the elapsed time since the last press of each direction button to use it for prioritization (presses closer to the direction change in time considered more important/intentional).
These modification made a huge difference back than. At least the "testing committee" (a.k.a. friends) had an immediate positive reaction, although I still had some ideas for improvement I was thrilled by the results. For more details about these enhancements, please check the old post. I'm jumping onto new stuff now!
The missed tap
One thing that was still bugging me related to these movement controls and the overall responsiveness of the game is the "missed tap". Due to one move action taking 12 frames, the direction change evaluation logic runs "rarely" and it is easy to miss it by a frame or two. An occasional maneuver is trying to change "lanes", by moving one tile perpendicular to our current direction, but continuing in the original direction right afterwards.
Some players (including me), try to achieve "lane changing" by holding down the main direction button and tapping the perpendicular direction button. The perpendicular direction gets bigger priority, due to the press occurring closer to direction evaluation in time, so it would be selected as the new direction for the player.
But being a short tap the button state may be released one or two frames early and usually the following happens:
Based on my guesswork, trying to achieve "lane changing" with a tap fails 3 out of 4 times (may be even worse). This is not hard to detect and sort-of can be made sure to be not mixed up with different intentions, so here comes my solution.
Implementation details
Instead of saving only one elapsed time since the press of a direction button, two timers are saved for the last two states (regardless whether it is pressed or released currently). This way we can buffer the most recent changes and the preceding actions of the players related to movement (buffering input events and their timings).
struct BufferedInput
{
bool pressed;
float currentElapsed;
float previousElapsed;
void update(bool state, float dt)
{
if (pressed == state)
{
currentElapsed += dt;
}
else
{
previousElapsed = currentElapsed;
currentElapsed = dt;
pressed = state; // pressed changed, timers swapped, current restarted...
}
}
}
That is the most crucial part of the solution. From now on we can detect the "missed taps" when evaluating the player movement, since we have all the required data. I think each game needs a little fine-tuning / trial and error regarding this part as timings and speed wildly varies between them, but my logic and my numbers may be useful:
const float FrameTime = 1f / 60f; // frame time in case of 60 fps
const float MovementTime = 12 * FrameTime;
bool detectBufferedTap(BufferedInput input)
{
if (!input.pressed)
{
var tapTime = input.currentElapsed + input.previousElapsed;
if (tapTime <= (MovementTime - 2 * FrameTime))
{
if (input.currentElapsed &lt;= input.previousElapsed)
{
return true
}
}
}
return false;
}
This means that the game considers a situation a missed tap, when a direction button is released during evaluation, a press occurred at least 2 frames after leaving the last tile (last direction evaluation) and the button was in a pressed state for at least as much time as it was released during these x <= 10 frames.
Taking these "missed taps" into account with just as much priority as a pressed input button, while the player is moving and a direction evaluation occurs, reverses the 3 out of 4 failures, so approximately 3 out of 4 times (maybe even better) a short tap is enough for a tile lane change. Tried tweaking this logic and the numbers, but could not really improve the consistency further. I'm happy with these results though. And again, after this update, controlling the game felt much better than before!
Probably there won't be updates for (nor posts about) Operation KREEP for a long while, since despite my efforts the game could only reach a miniscule audience + I'm getting fully occupied by my upcoming game Unified Theory, but who knows what the future holds...
Take care!
Why not have a little custom stack to store the last taps.