Your Game's Input System is Holding You Back
Hard coded keybindings. Frame-dependent input bugs. Players complaining they can't rebind controls.
Sound familiar?
Most developers treat input as an afterthought - just slap some IsKeyPressed(<hardcoded_key>)
calls and call it a day. Input is the interface for player interaction. Get it wrong and it can ruin a game.
Spend less than 15 minutes and less than 100 lines of code to write a proper input system that your players will thank you for
After reading this article, you'll never have the excuse that it's too hard to implement key rebinding.
What you'll learn:
How to abstract input states from raw keyboard events
Why configuration files beat hard-coded bindings every time
How to create a custom config file that auto-generates when invalid or missing
Input Abstraction
Raw input is messy. A key can be up, pressed (this frame), held down or released (this frame). Most input bugs come from confusing these states.
I use a Colemak keyboard layout with a split keyboard at home. I usually remap WASD to FRST as that's what's comfortable for my keyboard.
When I first played Cyberpunk 2077, the in-game menu did not allow rebinding the "pick up" key - it was stuck to T, which is the same as "Strafe Right" with my layout.
Many times during combat, I'd be fighting and strafing, my crosshair would go onto a corpse while I'm strafing right, and my character would do the pick-up-this-guy animation, leaving me totally vulnerable in the middle of combat.
It was infuriating. and made me quit playing the game for a while until I bothered to learn how to edit the config file manually. You don't want your players to experience that kind of thing - many of them won't come back.
Here's the input states for keys/buttons:
InputState :: enum {
Up,
Pressed,
Down,
Released,
}
Something I've found useful is to map actions to key bindings. Just like the player rebinds "pick up" to T, we are creating games with certain actions.
For our example game, we'll have four actions:
GameBinding :: enum {
Jump,
Sprint,
Left,
Right,
}
Now we can easily separate our actions from keys by using a map (or an enum array):
// Assuming we use raylib, this could be win32's VK_KEY type, or SDL3, or GLFW, or whatever
input_map: [GameBinding]rl.KeyboardKey
input_state: [GameBinding]InputState
We have two mappings. One for the binding, and one for the current state.
We'll create a procedure to update the state every frame:
input_update :: proc() {
for key, binding in input_map {
if rl.IsKeyPressed(key) {
input_state[binding] = .Pressed
} else if rl.IsKeyDown(key) {
input_state[binding] = .Down
} else if rl.IsKeyReleased(key) {
input_state[binding] = .Released
} else {
input_state[binding] = .Up
}
}
}
Raylib makes this easy because it has the same states: Pressed, Down, Up, Released.
Something like SDL3 would be a bit different:
event_update_sdl3 :: proc() {
event: sdl.Event
for sdl.PollEvent(&event) {
#partial switch event.type {
case .KEY_DOWN:
// Check if key was previously up, etc
case .KEY_UP:
// ...
// ... other event types ...
}
}
}
Config Files
Config files exist because we need a way to store the settings between launches of the game. Imagine if every time the player restarted the game they had to rebind the keys!
Let's say we want a config.ini file like this:
[bindings]
Jump = SPACE
Sprint = LEFT_SHIFT
Left = A
Right = D
We can create a simple default string that serves as our config file if none is present:
config_default_fmt :: `
[bindings]
Jump = %s
Sprint = %s
Left = %s
Right = %s
`
// later
config_default := fmt.tprintf(
config_default_fmt[1:],
string_from_key(.SPACE),
string_from_key(.LEFT_SHIFT),
string_from_key(.A),
string_from_key(.D),
)
The key values for different platforms and frameworks will probably be different. So, you may need to create a couple of conversion procedures, such as these:
string_from_key :: proc(key: rl.KeyboardKey, scratch := context.temp_allocator) -> string {
if key >= .A && key <= .Z {
return fmt.aprintf("%c", u8(key), allocator = scratch)
}
#partial switch key {
case .SPACE: return "SPACE"
case .LEFT_SHIFT: return "LEFT_SHIFT"
// ...
}
return "NULL"
}
key_from_string :: proc(s: string) -> rl.KeyboardKey {
if len(s) == 1 {
return rl.KeyboardKey(u8(s[0]))
}
switch s {
case "SPACE": return .SPACE
case "LEFT_SHIFT": return .LEFT_SHIFT
// ...
}
return .KEY_NULL
}
Creating and Loading the Config File
For this example game, we'll use a simple config.ini
file, which Odin conveniently has a parser for in core:encoding/ini
.
config_load_and_apply :: proc(input_map: ^[GameBinding]rl.KeyboardKey) {
is_config_valid := false
m: ini.Map
// Load existing config file
if os.exists(CONFIG_PATH) {
ini_map, err, ok := ini.load_map_from_path(CONFIG_PATH, context.temp_allocator)
if err == .None && ok {
is_config_valid = true
m = ini_map
}
}
// Rewrite default config file if invalid
if !is_config_valid {
// Generate defaults and save...
}
// Apply the config
input_map[.Jump] = key_from_string(m["bindings"]["Jump"])
// etc...
}
Generating the default config may look something like this:
config_default := fmt.tprintf(
config_default_fmt[1:], // remove the newline at the top
string_from_key(.SPACE),
string_from_key(.LEFT_SHIFT),
string_from_key(.A),
string_from_key(.D),
)
os.write_entire_file(CONFIG_PATH, transmute([]u8)config_default)
ini_map, err := ini.load_map_from_string(config_default, context.temp_allocator)
if err == .None {
m = ini_map
}
The generated config.ini:
[bindings]
Jump = SPACE
Sprint = LEFT_SHIFT
Left = A
Right = D
You would need to validate the config file based on your specific game's needs. For example, if some bindings are missing, that may be an invalid file and a new one will be generated.
The Usage
In your gameplay code, you can now refer to the actions your game cares about, rather than arbitrary keys:
if input_state[.Jump] == .Pressed && player.is_grounded {
// Jump
}
Key Takeaways
This system is simple, easy to implement, and configurable by the player.
Even if you don't have the budget to create a menu, players can change the bindings in the config file directly.
The entire thing comes to less than 100 lines of code.
It goes without saying that accessibility is another major consideration when designing input systems.
By mapping actions <-> keys, your gameplay code stays clear of nasty bugs due to hard-coded key bindings, and your players get to play how they want.
If you enjoy this newsletter and want to go deeper, with more structure, I made Program Video Games for you. Transform your game ideas into working systems from first principles.