Build a Game Engine

Input Design and Devices

Prerequisites

Article

Capturing and processing user input is a core mechanic of any video game - it's what makes the video game interactive with the user. Whether that's a keyboard and mouse, a gamepad, a joystick, or a VR or other input device, input and the way our application responds to it is how the user controls and interacts with the world around them.

We'll continue to leverage the GLFW library for user input here, but you could just as easily substitute SDL or another framework.

Since we're already calling glfwPollEvents in our event loop, it's easy to get started with capturing input - here's an example from GLFW's input guide:

int state = glfwGetKey(windowHandle, GLFW_KEY_E);
if (state == GLFW_PRESS)
{
    activate_airship();
}

However, if you've ever used other game engines like Unity, Unreal, or Godot, almost none of them implement input this way. Even if you haven't used them, you've likely played video games that allow you to change your key or button bindings. Jump can be Space, but you can set it to Enter or Left Mouse Click.

All of these engines implement the idea that users perform actions. Jump, move, accelerate, turn, shoot, etc. are all examples of action that the user can take. Instead of polling on whether or not the user has pressed the Space key, what we actually want to know is "Did the user jump?".

Gamepads and joysticks extend this even further because they have a range of input. A key on a keyboard is pressed or not pressed; a mouse button is clicked or not clicked. A joystick or gamepad stick however, can be 20% pressed, or 56% pressed, and it can be pressed in multiple directions at once.

Unity and Unreal engine treat these as axes. Unity gives you the GetAxis function for ranged input, and the GetButton function for checking buttons mapped to input. Unreal Engine also breaks these into two categories: action mappings - events that happen once and then reset, (eg. the user hits "jump") and axis mappings, for repeatable or ranged input events such as "move forward" or "turn left".

We'll provide two ways to interact with our system:

InputDevice

Although there will be device-specific functionality (eg. getting the cursor location), mostly we want to be able to treat input from all of our devices the same way. In addition, we'll want our input system to be able to poll all of our devices for their current state of any action-related buttons.

Let's create an InputDevice base class on which we can build all of our other device-specific classes:

class UInputDevice : public UObject {
public:
    UInputDevice() = default;
    virtual ~UInputDevice() = default;

    /**
     * Init and update functions (if needed by device)
     */
    virtual void Initialize() { }
    virtual void Update() { }

    /**
     * Get the state of a button
     */
    virtual float GetButtonState(int button) = 0;

    /**
     * Process a button action
     */
    virtual void SetButtonState(int button, float state) = 0;

    /**
     * Get device type
     */
    virtual int Type() = 0;

protected:
    // Device types
    enum UInputDeviceType {
        KEYBOARD = 1,
        MOUSE,
        GAMEPAD
    };
};

We've created Initialize and Update functions for our devices to override. We can use the Initialize function to get any necessary callbacks, and the Update function to poll for status where necessary. Then we have 3 pure virtual functions: GetButtonState, given a button int, SetButtonState for a button to a float value, and Type, which will tell us what type of device this is from the enum at the bottom. The Type function will be useful for our scripting libraries to find a device by type.

Input Events

Now that we have a generic device class, we can create an event class that can be published to our pub-sub event system whenever a particular user action is performed:

class UInputAction : public UEvent {
public:
    UInputAction() : amount(0.0f), device(0) { }
    UInputAction(std::string type, UInputDevice* device, float amount) { 
        this->type = type;
        this->amount = amount;
        this->device = device;
    }
    ~UInputAction() = default;

public:
    // User-specified action type (move forward, turn left, etc.)
    std::string type;

    // Amount (used for axis-based controls)
    float amount;

    // Device the action occured on
    UInputDevice* device;
};

The UInputAction event class is pretty simple: it takes a string action, amount that the user has applied the action (again 0 or 1 for key and mouse presses, between 0 and 1 for joysticks and gamepads), and a pointer to the device that triggered it.

InputSystem

Time to tie all of this together in our InputSystem. This system will be responsible for managing input devices, manage action mappings to devices and buttons, and trigger event actions.

Let's take a look:

#include <Core/USystem.h>
#include <IO/UInputDevice.h>
#include <IO/UInputAction.h>

class UInputSystem : public USystem {
public:
    UInputSystem() = default;
    ~UInputSystem() = default;

    /**
     * Update system
     */
    void Update(float delta);

    /**
     * Create an input device
     */
    template<class T> 
    T* CreateInputDevice() {
        T* device = new T();
        device->Initialize();
        m_devices.push_back(device);
        return(device);
    }

    /**
     * Get an input device (by class)
     */
    template<class T>
    T* FindInputDevice(int offset = 0) {
        auto it = m_devices.begin();
        int counter = 0;
        for (; it != m_devices.end(); it++) {
            T* device = dynamic_cast<T*>(*it);
            if (device) {
                if(counter == offset) return(device);
                counter++;
            }
        }

        return(0);
    }

    /**
     * Get an input device (by type)
     */
    UInputDevice* FindInputDevice(int type, int offset = 0);

    /**
     * Register a button action mapping
     */
    void RegisterActionMapping(UInputDevice* device, int button, std::string name);

    /**
     * Trigger a potential action event
     */
    void TriggerAction(UInputDevice* device, int button, float amount);

    /**
     * Get state of an action
     */
    float GetActionState(std::string action);

protected:
    // Struct to hold the mapping from device/button to action name
    struct UActionMapping {
        UInputDevice* device;
        int button;
        std::string action;
    };

protected:
    // Registered input devices
    std::vector<UInputDevice*> m_devices;

    // Registered action mappings (by name and device)
    std::map<std::string, std::vector<UActionMapping*>> m_actions;
    std::map<UInputDevice*, std::vector<UActionMapping*>> m_deviceActions;
};

The first function in our system is an override to the Update function, called once per frame. It has the simple job of simply calling the Update function for each of our registered input devices:

void UInputSystem::Update(float delta) {
    // Update all registered devices
    auto it = m_devices.begin();
    for (; it != m_devices.end(); it++) {
        (*it)->Update();
    }
}

The CreateInputDevice and FindInputDevice do just that - create a new input device and find an input device, both by class type. The CreateInputDevice function will also call the Initialize function on our input device.

We can also call the overloaded non-template version of FindInputDevice to get a device by its enum or integer type:

UInputDevice* UInputSystem::FindInputDevice(int type, int offset) {
    auto it = m_devices.begin();
    int counter = 0;
    for (; it != m_devices.end(); it++) {
        if ((*it)->Type() == type) {
            if (offset == counter) return(*it);
            counter++;
        }
    }

    return(0);
}

Finally, we have three functions that help us manage action mappings: RegisterActionMapping will map a button on a given device to an action (specified by a string); TriggerAction will, given a device and a button, trigger an action if it is mapped; and GetActionState will search all devices registered to an action name for their current state and return it - for example, if both W and the Up key are mapped to "move forward", it will check the state of both and return it.

We've created a struct here to hold an individual mapping called UActionMapping:

struct UActionMapping {
    UInputDevice* device;
    int button;
    std::string action;
};

When we create an action mapping, we're actually storing it two ways, as seen in our InputSystem:

std::map<std::string, std::vector<UActionMapping*>> m_actions;
std::map<UInputDevice*, std::vector<UActionMapping*>> m_deviceActions;

The first map is a map from action name to mapping struct - we'll use this one in GetActionState to find all of the devices that could generate a certain action. The second is a map of input devices to any action mappings associated to it - we'll use that in the TriggerAction method to narrow down the list of mappings we need to go through. And of course, in the RegisterActionMapping function, we'll write back to both:

void UInputSystem::RegisterActionMapping(UInputDevice* device, int button, std::string name) {
    // Initialize arrays if necessary
    auto it = m_actions.find(name);
    if (it == m_actions.end()) {
        m_actions[name] = std::vector<UActionMapping*>();
    }

    auto dit = m_deviceActions.find(device);
    if (dit == m_deviceActions.end()) {
        m_deviceActions[device] = std::vector<UActionMapping*>();
    }

    // Create an action mapping
    UActionMapping* map = new UActionMapping();
    map->action = name;
    map->button = button;
    map->device = device;

    // Map by name and device for performance
    m_actions[name].push_back(map);
    m_deviceActions[device].push_back(map);
}

The first two blocks of code here simply initialize our std::vector nested inside our std::map if it doesn't yet exist. Then we simply add the ActionMapping to it.

Now, given an action mapping name (eg. "move forward"), we can implement GetActionState:

float UInputSystem::GetActionState(std::string action) {
    // Case where action not registered...
    auto it = m_actions.find(action);
    if (it == m_actions.end()) return(0.0f);

    // Calculate the action state
    float value = 0.0f;
    for (auto ait = it->second.begin(); ait != it->second.end(); ait++) {
        value += (*ait)->device->GetButtonState((*ait)->button);
    }

    return(value);
}

Likewise, given an input device a button that was pressed (generally called from the device-specific implementations), we can implement the TriggerAction function:

void UInputSystem::TriggerAction(UInputDevice* device, int button, float amount) {
    UEventSystem* eventSystem = GetUSystem<UEventSystem>();

    // Check for actions registered to this device
    auto it = m_deviceActions.find(device);
    if (it == m_deviceActions.end()) return;

    // Loop over its action mappings
    auto ait = it->second.begin();
    for (; ait != it->second.end(); ait++) {
        if ((*ait)->device == device && (*ait)->button == button) {
            // Publish an UInputAction event
            eventSystem->Publish(new UInputAction((*ait)->action, device, amount));
        }
    }
}

This function will publish our UInputAction event to any subscribers who have registered, giving them the action, device, and amount.

Conclusion

We now have our InputDevice class, from which we can create other device-specific classes, and that gives us a generic base class that we can store, manage, and poll from our InputSystem. Our InputSystem will create and manage devices, handle registration for action mappings, and allow us to poll their state across devices. In addition, it will trigger InputAction events for any changes in state on action associated buttons.

Next we'll implement some device-specific classes: a mouse, keyboard, and gamepad.