How to flip input handling on its head with action mapping
by admin
Suppose you’re working on a space action game called “Actioneroids”, which sounds a bit like something your doctor would prescribe a cream for. You started from scratch and got something on the screen as fast as possible. You wrote some code in C++ to create a window, loaded some ship graphics and now you want to add player input.
For a first test, the player should be able to rotate the ship using the left and right arrow keys and accelerate using the up and down arrow keys.
Your first pass will probably look very similar to this:
void Player::Player(Keyboard* a_Keyboard) : m_Keyboard(a_Keyboard) , m_Position(glm::vec2(0.0f, 0.0f)) , m_Velocity(glm::vec2(0.0f, 0.0f)) , m_Angle(0.0f) , m_Speed(0.0f) , m_TimeCooldown(0.0f) { } void Player::Tick(float a_DeltaTime) { if (m_Keyboard->IsKeyPressed(VK_LEFT)) { m_Angle += 3.0f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(VK_RIGHT)) { m_Angle -= 3.0f * a_DeltaTime; } if (m_Keyboard->IsKeyPresed(VK_UP)) { m_Speed = glm::clamp(m_Speed + (1.5f * a_DeltaTime), 0.0f, 4.5f); } if (m_Keyboard->IsKeyPresed(VK_DOWN)) { m_Speed = glm::clamp(m_Speed - (1.5f * a_DeltaTime), 0.0f, 4.5f); } m_Velocity = glm::vec2(glm::cos(m_Angle), glm::sin(m_Angle)) * m_Speed; m_Position += m_Velocity * a_DeltaTime; if (m_TimeCooldown > 0.0f) { m_TimeCooldown -= 0.1f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(VK_SPACE)) { ShootBullet(m_Position, m_Velocity); m_TimeCooldown += 3.0f; } }
After running the game, it appears everything works as intended. The player can rotate and move the ship using the arrow keys and fire with the spacebar. You relax in the knowledge of a job well done.
Rebinding keys
One of those pesky designers is at your desk. He says that the player input thus far is fine, but some players prefer using “WASD”. Alright, let’s add that as well:
void Player::Tick(float a_DeltaTime) { if (m_Keyboard->IsKeyPressed(VK_LEFT) || m_Keyboard->IsKeyPressed('A')) { m_Angle += 3.0f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(VK_RIGHT) || m_Keyboard->IsKeyPressed('D')) { m_Angle -= 3.0f * a_DeltaTime; } if (m_Keyboard->IsKeyPresed(VK_UP) || m_Keyboard->IsKeyPressed('W')) { m_Speed = glm::clamp(m_Speed + (1.5f * a_DeltaTime), 0.0f, 4.5f); } if (m_Keyboard->IsKeyPresed(VK_DOWN) || m_Keyboard->IsKeyPressed('S')) { m_Speed = glm::clamp(m_Speed - (1.5f * a_DeltaTime), 0.0f, 4.5f); } m_Velocity = glm::vec2(glm::cos(m_Angle), glm::sin(m_Angle)) * m_Speed; m_Position += m_Velocity * a_DeltaTime; if (m_TimeCooldown > 0.0f) { m_TimeCooldown -= 0.1f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(VK_SPACE)) { ShootBullet(m_Position, m_Velocity); m_TimeCooldown += 3.0f; } }
Oh, but some players are using weird keyboard layouts like AZERTY and DVORAK, so what we really want is the ability to remap the keys.
Alright, it looks like we have to be a bit more invasive in our refactorings. Before we begin, let’s make a list of the requirements so far:
- Player input is done using the keyboard.
- The player ship can be moved using the arrow keys.
- The player ship can be moved using another combination of keys.
- All keys should be configurable.
If we spell out the requirements like that, it becomes a bit more obvious what should be done. First, we’ll make a struct that we can use for storing key bindings.
struct KeyBinding { int key_first; int key_second; };
Next, we’ll define a number of these structs for each of our key bindings: left, right, up, down and shoot.
void Player::Player(Keyboard* a_Keyboard) : m_Keyboard(a_Keyboard) , m_Position(glm::vec2(0.0f, 0.0f)) , m_Velocity(glm::vec2(0.0f, 0.0f)) , m_Angle(0.0f) , m_Speed(0.0f) , m_TimeCooldown(0.0f) { LoadDefaultKeyBindings(); } void Player::LoadDefaultKeyBindings() { m_BindingLeft.key_first = VK_LEFT; m_BindingLeft.key_second = 'A'; m_BindingRight.key_first = VK_RIGHT; m_BindingRight.key_second = 'D'; m_BindingUp.key_first = VK_UP; m_BindingUp.key_second = 'W'; m_BindingDown.key_first = VK_DOWN; m_BindingDown.key_second = 'S'; m_BindingShoot.key_first = VK_SPACE; m_BindingShoot.key_second = -1; }
With a few small changes, our Player class can now support any key binding the players can think of.
void Player::Tick(float a_DeltaTime) { if (m_Keyboard->IsKeyPressed(m_BindingLeft.key_first) || m_Keyboard->IsKeyPressed(m_BindingLeft.key_second)) { m_Angle += 3.0f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(m_BindingRight.key_first) || m_Keyboard->IsKeyPressed(m_BindingRight.key_second)) { m_Angle -= 3.0f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(m_BindingUp.key_first) || m_Keyboard->IsKeyPressed(m_BindingUp.key_second)) { m_Speed = glm::clamp(m_Speed + (1.5f * a_DeltaTime), 0.0f, 4.5f); } if (m_Keyboard->IsKeyPressed(m_BindingDown.key_first) || m_Keyboard->IsKeyPressed(m_BindingDown.key_second)) { m_Speed = glm::clamp(m_Speed - (1.5f * a_DeltaTime), 0.0f, 4.5f); } m_Velocity = glm::vec2(glm::cos(m_Angle), glm::sin(m_Angle)) * m_Speed; m_Position += m_Velocity * a_DeltaTime; if (m_TimeCooldown > 0.0f) { m_TimeCooldown -= 0.1f * a_DeltaTime; } if (m_Keyboard->IsKeyPressed(m_BindingShoot.key_first) || m_Keyboard->IsKeyPressed(m_BindingShoot.key_second)) { ShootBullet(m_Position, m_Velocity); m_TimeCooldown += 3.0f; } }
Different input methods
Marketing is at your desk this time. They say that “Actioneroids” is shaping up to be a blockbuster hit, but they’d like to release simultaneously for PC, Xbox 360 and iPhone. So the game will need to support keyboard, controller and touchscreen input methods.
Oh, and the controls will still be bindable, right?
You sputter and fluster. The game wasn’t designed for those input methods! It’s going to be a maintenance nightmare! And the keys have to be bindable?!
But you’ll give it your best shot anyway.
Let’s modify the constructor. We’ll make a new class, an InputHandler, that has handles to all input methods. We’ll pass this class to the Player class.
Player::Player(InputHandler* a_InputHandler) : m_InputHandler(a_InputHandler) , m_Position(glm::vec2(0.0f, 0.0f)) , m_Velocity(glm::vec2(0.0f, 0.0f)) , m_Angle(0.0f) , m_Speed(0.0f) , m_TimeCooldown(0.0f) { }
The Tick method now has to account for all these different input methods, running on all these platforms. It’s… not going to be pretty.
void Player::Tick(float a_DeltaTime) { Keyboard* kb = m_InputHandler->GetKeyboard(); Gamepad* gp = m_InputHandler->GetGamepad(); Touchscreen* ts = m_InputHandler->GetTouchscreen(); #if PLATFORM_PC if (kb->IsKeyPressed(m_BindingLeft.key_first) || kb->IsKeyPressed(m_BindingLeft.key_second)) #elif PLATFORM_XBOX if (gp->IsButtonPressed(GAMEPAD_BUTTON_DPAD_LEFT)) #elif PLATFORM_IPHONE if (ts->IsAreaTouched(glm::vec2(20.0f, 20.0f), glm::vec2(120.0f, 120.0f)) #endif { m_Angle += 3.0f * a_DeltaTime; } // snipped for sanity }
You feel dirty, but it works. On all platforms at that. Marketing loves it! And the designers too. But they are wondering if you could maybe add controller support for PC as well…?
Action mapping to the rescue
All we’ve done so far is query the state of the different input devices and react to their output. But that assumes the actions are bound in the same way. For example, if you’re using a controller to rotate the ship, you use values between 0 and 1. This allows fine-grained control of the ship’s movement. But on a keyboard, you don’t get a percentage value for a key. You get 0 (nothing) or 1 (maximum). When you don’t take this into account, you can end up with a solution that works great with a controller, but feels awful when using a keyboard and mouse.
So what is action mapping and how does it help?
When using action mapping, you check for an action, but don’t care about the input. In the example, we already have four actions: rotate left, rotate right, accelerate and decelerate. The action mapper takes the name of an action and returns a normalized value as a float. Internally, it queries the different input methods and converts their values to the expected output.
For our action names, we will use strings. But don’t feel constrained. You can use incrementing integers, hashed strings or anything else, as long as it is unique for that action.
static const std::string g_Action_Player_RotateLeft = "Action_Player_RotateLeft"; static const std::string g_Action_Player_RotateRight = "Action_Player_RotateRight"; static const std::string g_Action_Player_Accelerate = "Action_Player_Accelerate"; static const std::string g_Action_Player_Decelerate = "Action_Player_Decelerate"; static const std::string g_Action_Player_Shoot = "Action_Player_Shoot";
Note that some of the actions can be combined. A rotation to the left is negative, while a rotation to the right is positive. So a rotation can be mapped to -1…0…1. The same is true for acceleration.
Now we only need three actions:
static const std::string g_Action_Player_Rotation = "Action_Player_Rotation"; static const std::string g_Action_Player_Acceleration = "Action_Player_Acceleration"; static const std::string g_Action_Player_Shoot = "Action_Player_Shoot";
We’ll put these action names in a header called “ActionNames.h”.
Without looking at the implementation for the action mapper just yet, what will the Player class look like now? A lot simpler:
void Player::Player(ActionMapper* a_ActionMapper) : m_ActionMapper(a_ActionMapper) , m_Position(glm::vec2(0.0f, 0.0f)) , m_Velocity(glm::vec2(0.0f, 0.0f)) , m_Angle(0.0f) , m_Speed(0.0f) , m_TimeCooldown(0.0f) { } void Player::Tick(float a_DeltaTime) { m_Angle += 3.0f * m_ActionMapper->GetAction(g_Action_Player_Rotation) * a_DeltaTime; m_Speed += 1.5f * m_ActionMapper->GetAction(g_Action_Player_Acceleration) * a_DeltaTime; m_Speed = glm::clamp(m_Speed, 0.0f, 4.5f); m_Velocity = glm::vec2(glm::cos(m_Angle), glm::sin(m_Angle)) * m_Speed; m_Position += m_Velocity * a_DeltaTime; if (m_TimeCooldown > 0.0f) { m_TimeCooldown -= 0.1f * a_DeltaTime; } if (m_ActionMapper->GetAction(g_Action_Player_Shoot) > 0.0f) { ShootBullet(m_Position); m_TimeCooldown += 3.0f; } }
Internally, our action mapper will ask each of its handlers: do you recognize this action? If so, what value is it? Only one of the handlers gets to decide the output value, so the order is important.
float ActionMapper::GetAction(const std::string& a_Name) const { float value = 0.0f; for (std::vector<IInputHandler*>::const_iterator handler_it = m_InputHandlers.begin(); handler_it != m_InputHandlers.end(); ++handler_it) { IInputHandler* handler = *handler_it; if (handler->GetAction(a_Name, &value)) { break; } } return value; }
Let’s look at the handler for the keyboard, because it was the first one we added. The implementation for the virtual GetAction method should compare the name of the action to the ones it knows. Some actions may still be platform or input-method specific.
bool KeyboardHandler::GetAction(const std::string& a_Name, float& a_Value) { if (a_Name == g_Action_Player_Rotation) { if (m_Keyboard->IsKeyPressed(m_BindingLeft.key_first) || m_Keyboard->IsKeyPressed(m_BindingLeft.key_second)) { a_Value = -1.0f; } else if (m_Keyboard->IsKeyPressed(m_BindingRight.key_first) || m_Keyboard->IsKeyPressed(m_BindingRight.key_second)) { a_Value = 1.0f; } return true; } else if (a_Name == g_Action_Player_Acceleration) { if (m_Keyboard->IsKeyPressed(m_BindingUp.key_first) || m_Keyboard->IsKeyPressed(m_BindingUp.key_second)) { a_Value = 1.0f; } else if (m_Keyboard->IsKeyPressed(m_BindingDown.key_first) || m_Keyboard->IsKeyPressed(m_BindingDown.key_second)) { a_Value = -1.0f; } return true; } else if (a_Name == g_Action_Player_Shoot) { if (m_Keyboard->IsKeyPressed(m_BindingShoot.key_first) || m_Keyboard->IsKeyPressed(m_BindingShoot.key_second)) { a_Value = 1.0f; } return true; } else { return false; } }
It looks very similar to our earlier incarnation, doesn’t it? Note that even if a button is not pressed, the method returns true. That’s because the return value indicates “hey I know this action!” instead of “the user is doing this action”.
The major advantage is that it is now extremely easy to add a new input method. Simply build a new InputHandler and add it to the action mapper.
It’s not a silver bullet
You know how these posts go. This is a typical “I found a hammer, now everything can be treated as a nail!” post. I’m here to tell you that that is not true. There are distinct and clear disadvantages you must consider before implementing action mapping.
It’s a performance hit
You can’t expect to get the same performance when you’re comparing a string (an action) every frame instead of looking up a boolean (key pressed). It can be mitigated by comparing unique identifiers instead of strings, but you’ll still have to evaluate every incoming action request.
It’s more work
Games have been built and shipped with direct input mapping. It’s not a huge sin to use it. If you only plan to support keyboard and mouse for instance, it’s a lot of wasted effort to abstract that away behind a tree of interfaces.
It’s harder to debug
When you’re doing direct input mapping, it’s easy to set a breakpoint and inspect the value of an input. Was button A pressed? Yes, so says the KeyboardHandler. But when you use action mapping, it’s a lot harder to find your action in a sea of unrelated ones. The best approach is divide-and-conquer: split the GetAction method into multiple submethods, which only expect a small range of actions.
Not all input can be mapped in the same manner
In our game, we could have a guided missile. With the controller, you guide the missile using the right stick. When using mouse and keyboard, the missile homes in on the cursor position. Obviously, these actions cannot be mapped in the same manner. The controller uses a velocity for the missile to steer it, while the mouse sets the position to home in directly.
For these situations, it is often best to have two sets of actions, where each set is implemented by one input method, but ignored by the other.
Conclusion
Even with these downsides, I hope I’ve shown with a clear and concrete example what the benefits are: it’s easier to add new types of input, which means it’s easier to port to other platforms.
“It’s a performance hit!”
Because you implemented it weirdly. But even then, how many actions, how many controls are you going to be monitoring per frame? Would this ever remotely be a bottleneck in any real game scenario?
Awesome article.
Thanks for that! It’s just the answer I nedeed.