Tired of Rewriting Keybind Logic? InputManager Handles It All—Context, Debounce, Gamepad, Touch

InputManager — Modular Input Binding with Context and Debounce

A clean, scalable input system for Roblox that supports keyboard, gamepad, and touch inputs with context filtering, debounce logic, and centralized registration.

Why Use This?

Roblox’s default input handling is device-specific and scattered across scripts. This module solves that by:

  • Centralizing input logic in one place
  • Supporting multiple input types (keyboard, gamepad, touch)
  • Preventing accidental double triggers with debounce
  • Enabling context-based input filtering (e.g., gameplay vs menu)
  • Making input registration declarative and readable
  • Preparing for future expansion, like rebinding, priority sorting, and input stacks

Whether you’re building a mission system, a UI-heavy experience, or a sandbox toolset, this module gives you clean control over player input.

Features

  • Register() inputs with context, debounce, and priority
  • Supports Enum.KeyCode and Enum.UserInputType
  • Context switching (SetContext())
  • Debounce with custom timing: Debounce = {true, 0.05}
  • Touch and gamepad support
  • Unregister bindings
  • Debug printout

Installation

  1. Create a ModuleScript named InputManager in ReplicatedStorage
  2. Paste the full module code below
  3. Create a LocalScript in StarterPlayerScripts to use it

Usage Example

local Input = require(game.ReplicatedStorage.InputManager)

Input.Register("OpenMenu", {
    Key = Enum.KeyCode.M,
    Callback = function()
        print("Menu opened")
    end,
    Context = "gameplay",
    Debounce = {true, 0.05}
})

Input.SetContext("gameplay")

API Reference

InputManager.Register(name: string, config: table)

Registers an input binding.

Config fields:

  • Key: Enum.KeyCode or Enum.UserInputType
  • Callback: function to run
  • Context: optional string (default "global")
  • Type: optional string ("Tap", "Hold", "DoubleTap")
  • Priority: optional number (for future sorting)
  • Debounce: optional {boolean, number} — e.g. {true, 0.05} for 50ms cooldown

InputManager.Unregister(name: string)

Removes a registered input binding by name.

InputManager.SetContext(context: string)

Sets the active input context. Only bindings matching this context (or "global") will respond.

InputManager.Debug()

Prints all registered bindings to the console for inspection.

Context System

Use contexts to isolate input logic by game state:

Context Example Inputs
"gameplay" Move, jump, interact
"menu" Navigate, confirm, cancel
"cutscene" Skip, pause, toggle subtitles
"buildMode" Place, rotate, delete, snap

Switch contexts with:

InputManager.SetContext("menu")

Expansion Roadmap

  • Context stack (push/pop)
  • Hold + double-tap detection
  • Priority sorting
  • Rebinding UI
  • Input logging

Full Module Code

local UserInputService = game:GetService("UserInputService")
local InputManager = {}

local bindings = {}
local activeContext = "global"
local lastInputTime = {}

local InputType = {
    Tap = "Tap",
    Hold = "Hold",
    DoubleTap = "DoubleTap"
} Function on InputManager.Register(name: string, config: {
    Key: Enum.KeyCode | Enum.UserInputType,
    Callback: (...any) -> (),
    Context: string?,
    Type: string?,
    Priority: number?,
    Debounce: {boolean, number}?
})
    assert(config.Key, "Input must have a Key")
    assert(config.Callback, "Input must have a Callback")

    bindings[name] = {
        Key = config.Key,
        Callback = config.Callback,
        Context = config.Context or "global",
        Type = config.Type or InputType.Tap,
        Priority = config.Priority or 0,
        Debounce = config.Debounce or {false, 0.2}
    }
end

function InputManager.Unregister(name: string)
    bindings[name] = nil
end

function InputManager.SetContext(context: string)
    activeContext = context
end

local function isDebounced(name: string)
    local bind = bindings[name]
    if not bind or not bind.Debounce[1] then return false end
    local now = tick()
    local last = lastInputTime[name] or 0
    if now - last < bind.Debounce[2] then return true end
    lastInputTime[name] = now
    return false
end

local function handleInput(input, gameProcessed)
    if gameProcessed then return end

    for name, bind in pairs(bindings) do
        if bind.Context ~= activeContext and bind.Context ~= "global" then continue end
        if input.KeyCode == bind.Key or input.UserInputType == bind.Key then
            if isDebounced(name) then continue end
            bind.Callback(input)
        end
    end
end

UserInputService.InputBegan:Connect(handleInput)
UserInputService.TouchTap:Connect(function(touchPositions, processed)
    handleInput({UserInputType = Enum.UserInputType.Touch}, processed)
end)

UserInputService.InputChanged:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.Gamepad1 then
        handleInput(input, false)
    end
end)

function InputManager.Debug()
    for name, bind in pairs(bindings) do
        print(`[Input] {name} → {bind.Key} ({bind.Context})`)
    end
end

return InputManager

I created this because I was tired of writing more code to cancel overlapping keybinds.

7 Likes

Does Input Action System ring a bell at all? Why use this module over that?

Yeah, I’m familiar with the Input Action System. I would use this module because it’s more flexible and modular:

  • You don’t have to keep all your bindings in one script—it supports distributed configs across multiple modules.
  • Built-in debounce handling means you don’t have to manually wrap every input callback.
  • It’s easier to scale and maintain, especially in larger projects with layered input logic.

That said, if the Input Action System works better for your setup, go for it. I just find this approach cleaner and more developer-friendly.

2 Likes

UPDATE 0.1 — InputManager

InputManager now supports extended debounce control, cross-script access, and runtime input inspection. This update makes it easier to manage input logic across gameplay, UI, and tools—without requiring the rewriting of handlers or scattering logic.

What’s New

1. Extended Debounce System

Debounce is now passed as a structured table:

Debounce = {
    true,         -- enable debounce
    0.05,         -- cooldown duration in seconds
    "perKey",     -- mode: "perKey", "perBinding", or "global"
    false         -- allow override (true = bypass cooldown if forced)
}

New methods:

  • InputManager.IsDebounced(name) — check if a binding is cooling down
  • InputManager.ResetDebounce(name) — manually clear cooldown
  • InputManager.ForceTrigger(name, input) — bypass debounce and run callback

2. Cross-Script Access

You can now inspect and modify bindings from any script:

InputManager.UpdateBinding("Jump", {
    Callback = function()
        print("Jump logic updated")
    end
})

local bind = InputManager.GetBinding("Jump")
print(bind.Key, bind.Context)

3. Runtime Inspection API

Includes:

  • HasBinding(name)
  • ListBindings()
  • IsDebounced(name)
  • ResetDebounce(name)
  • ForceTrigger(name, input)

Full Module Code

local UserInputService = game:GetService("UserInputService")
local InputManager = {}

local bindings = {}
local activeContext = "global"
local lastInputTime = {}
local globalDebounceTime = 0

local InputType = {
    Tap = "Tap",
    Hold = "Hold",
    DoubleTap = "DoubleTap"
}

function InputManager.Register(name, config)
    assert(config.Key, "Input must have a Key")
    assert(config.Callback, "Input must have a Callback")

    bindings[name] = {
        Key = config.Key,
        Callback = config.Callback,
        Context = config.Context or "global",
        Type = config.Type or InputType.Tap,
        Priority = config.Priority or 0,
        Debounce = config.Debounce or {false, 0.2, "perKey", false}
    }
end

function InputManager.UpdateBinding(name, newConfig)
    local existing = bindings[name]
    if not existing then return false end
    for key, value in pairs(newConfig) do
        existing[key] = value
    end
    return true
end

function InputManager.GetBinding(name)
    return bindings[name]
end

function InputManager.HasBinding(name)
    return bindings[name] ~= nil
end

function InputManager.ListBindings()
    local list = {}
    for name, bind in pairs(bindings) do
        table.insert(list, {Name = name, Key = bind.Key, Context = bind.Context})
    end
    return list
end

function InputManager.Unregister(name)
    bindings[name] = nil
end

function InputManager.SetContext(context)
    activeContext = context
end

function InputManager.ResetDebounce(name)
    lastInputTime[name] = 0
end

function InputManager.IsDebounced(name)
    local bind = bindings[name]
    if not bind or not bind.Debounce[1] then return false end
    local mode = bind.Debounce[3] or "perKey"
    local now = tick()
    if mode == "global" then
        return now - globalDebounceTime < bind.Debounce[2]
    else
        local last = lastInputTime[name] or 0
        return now - last < bind.Debounce[2]
    end
end

function InputManager.ForceTrigger(name, input)
    local bind = bindings[name]
    if bind then
        bind.Callback(input)
    end
end

local function isDebounced(name)
    local bind = bindings[name]
    if not bind or not bind.Debounce[1] then return false end
    local mode = bind.Debounce[3] or "perKey"
    local allowOverride = bind.Debounce[4] or false
    local now = tick()
    if mode == "global" then
        if now - globalDebounceTime < bind.Debounce[2] then return not allowOverride end
        globalDebounceTime = now
        return false
    else
        local last = lastInputTime[name] or 0
        if now - last < bind.Debounce[2] then return not allowOverride end
        lastInputTime[name] = now
        return false
    end
end

local function handleInput(input, gameProcessed)
    if gameProcessed then return end
    for name, bind in pairs(bindings) do
        if bind.Context ~= activeContext and bind.Context ~= "global" then continue end
        if input.KeyCode == bind.Key or input.UserInputType == bind.Key then
            if isDebounced(name) then continue end
            bind.Callback(input)
        end
    end
end

UserInputService.InputBegan:Connect(handleInput)
UserInputService.TouchTap:Connect(function(touchPositions, processed)
    handleInput({UserInputType = Enum.UserInputType.Touch}, processed)
end)

UserInputService.InputChanged:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.Gamepad1 then
        handleInput(input, false)
    end
end)

function InputManager.Debug()
    for name, bind in pairs(bindings) do
        print(`[Input] {name} → {bind.Key} ({bind.Context})`)
    end
end

return InputManager

Example Usage

InputManager.Register("PingServer", {
    Key = Enum.KeyCode.P,
    Callback = function()
        game.ReplicatedStorage.Ping:FireServer()
    end,
    Context = "gameplay",
    Debounce = {true, 0.1, "perBinding", false}
})

if InputManager.IsDebounced("PingServer") then
    print("Ping is cooling down")
end

InputManager.ForceTrigger("PingServer", nil)

Context Behavior

Bindings with Context = "global" are always active, regardless of the current context. Use this for universal actions like debug keys or emergency triggers.

Coming Soon

  • Hold and double-tap detection
  • Context stack (push/pop)
  • Priority sorting
3 Likes

ima also stop posting the module code, next update, its gonna be a auto updater loader, or smth. maybe a model.

1 Like

Update soon, just been busy with my own stuff and life.

1 Like

Hey there!

I was thinking on making an input handler like this, so I searched to see if someone already made it and I stumbled upon this topic.
Great module! I’ll be using it in some projects if you don’t mind.

A tiny suggestion. You can make the bidings table be inside InputManager by setting InputManager.bidings = {}.
This makes it easier to accsses bindings directly from scripts with no need for a function.

Eg. In a script you can require the InputManager and get the bidings by just writting InputManager.bidings.ExampleBinding

Have a great day ;]

Hello! This is a really good module and something I’ll use for my future projects!
I do have a question, do you mind if I “fork” it into my own version? With some changes, etc..
I would credit you, of course.
Have an awesome day!

No, I actually don’t mind if you don’t. Just if you do, please credit me. But sure, go ahead.

UPDATE 0.2 — InputManager

InputManager has been upgraded with context stacks, hold + double‑tap detection, priority sorting, rebinding, and input logging. This update makes it easier to manage complex input flows across gameplay, menus, and cutscenes — while giving players the ability to customize controls at runtime.

What’s New

1. Context Stack (Push/Pop)

You can now push and pop contexts dynamically:

InputManager.PushContext("menu")
InputManager.PopContext()
InputManager.ClearContext()
  • Only the top context is active.
  • "global" bindings remain active across all contexts.

2. Hold + Double‑Tap Detection

Built‑in detection for advanced input types:

InputManager.Register("Sprint", {
    Key = Enum.KeyCode.LeftShift,
    Type = "Hold",
    HoldThreshold = 0.5, -- per-binding threshold
    Callback = function(ev)
        print("Sprint held for", ev.duration)
    end
})

InputManager.Register("Dash", {
    Key = Enum.KeyCode.D,
    Type = "DoubleTap",
    Callback = function(ev)
        print("Dash double tap!")
    end
})
  • Hold: Fires when key held longer than HoldThreshold (default 0.35s).
  • DoubleTap: Fires when pressed twice within doubleTapWindow (default 0.3s).
  • Optional RepeatRate for repeated hold callbacks.

3. Priority Sorting

Bindings now respect priority values:

Priority = 10 -- higher = earlier execution
  • When multiple bindings match the same input, higher priority fires first.

4. Rebinding System

Players can now change keybinds at runtime:

InputManager.BeginRebind("Jump", {timeout=5})
  • Listens for the next input and updates the binding’s key.
  • Options: timeout, acceptGameProcessed, context.
  • Perfect for building a rebinding menu.

5. Input Logging

All input events are now logged:

local logs = InputManager.GetLogs()
InputManager.ClearLogs()
  • Logs Tap, Hold, DoubleTap, HoldRepeat, and Rebind events.
  • Useful for debugging or analytics.

Full Module Code

-- InputManager v0.2
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")

local InputManager = {}
InputManager.bindings = {}
InputManager.contextStack = {}
InputManager.logs = {}

local lastInputTime = {}
local globalDebounceTime = 0
local activeHolds = {}
local doubleTapTimers = {}
local listeningForRebind = {}
local holdThreshold = 0.35
local doubleTapWindow = 0.3

local InputType = {
    Tap = "Tap",
    Hold = "Hold",
    DoubleTap = "DoubleTap"
}

-- Context stack
function InputManager.PushContext(context) table.insert(InputManager.contextStack, context) end
function InputManager.PopContext() table.remove(InputManager.contextStack, #InputManager.contextStack) end
function InputManager.ClearContext() table.clear(InputManager.contextStack) end
local function topContext() return InputManager.contextStack[#InputManager.contextStack] or "global" end

-- Register
function InputManager.Register(name, config)
    assert(config.Key, "Input must have a Key")
    assert(config.Callback, "Input must have a Callback")
    InputManager.bindings[name] = {
        Key = config.Key,
        Callback = config.Callback,
        Context = config.Context or "global",
        Type = config.Type or InputType.Tap,
        Priority = config.Priority or 0,
        Debounce = config.Debounce or {false, 0.2, "perKey", false},
        Enabled = config.Enabled ~= false,
        RepeatRate = config.RepeatRate or 0,
        HoldThreshold = config.HoldThreshold or holdThreshold
    }
end

-- Update / Unregister
function InputManager.UpdateBinding(name, newConfig)
    local existing = InputManager.bindings[name]
    if not existing then return false end
    for k,v in pairs(newConfig) do existing[k] = v end
    return true
end
function InputManager.Unregister(name) InputManager.bindings[name] = nil end

-- Debounce helpers
function InputManager.ResetDebounce(name) lastInputTime[name] = 0 end
function InputManager.IsDebounced(name)
    local bind = InputManager.bindings[name]
    if not bind or not bind.Debounce[1] then return false end
    local mode = bind.Debounce[3] or "perKey"
    local t = tick()
    if mode == "global" then return t - globalDebounceTime < bind.Debounce[2]
    else return t - (lastInputTime[name] or 0) < bind.Debounce[2] end
end

-- Rebinding
function InputManager.BeginRebind(name, options)
    listeningForRebind[name] = {timeout = options and options.timeout or 10, start = tick()}
end
function InputManager.CancelRebind(name) listeningForRebind[name] = nil end

-- Logging
function InputManager.Log(event, payload) table.insert(InputManager.logs, {t=tick(), e=event, p=payload}) end
function InputManager.GetLogs() return InputManager.logs end
function InputManager.ClearLogs() table.clear(InputManager.logs) end

-- Input handling
local function handleInputBegan(input, gameProcessed)
    -- Rebind capture
    for name, session in pairs(listeningForRebind) do
        if tick() - session.start <= session.timeout then
            local b = InputManager.bindings[name]
            if b then b.Key = input.KeyCode ~= Enum.KeyCode.Unknown and input.KeyCode or input.UserInputType end
            InputManager.Log("Rebind", {name=name, key=b.Key})
            listeningForRebind[name] = nil
            return
        end
    end
    if gameProcessed then return end
    local ctx = topContext()
    -- Collect candidates
    local cands = {}
    for name, bind in pairs(InputManager.bindings) do
        if bind.Enabled and (bind.Context == ctx or bind.Context == "global") and (input.KeyCode == bind.Key or input.UserInputType == bind.Key) then
            table.insert(cands, {name=name, bind=bind})
        end
    end
    table.sort(cands, function(a,b) return a.bind.Priority > b.bind.Priority end)
    for _, item in ipairs(cands) do
        local name, bind = item.name, item.bind
        if InputManager.IsDebounced(name) then continue end
        if bind.Type == InputType.Hold then
            activeHolds[name] = {start=tick(), bind=bind, input=input, lastRepeat=0}
        elseif bind.Type == InputType.DoubleTap then
            local last = doubleTapTimers[name]
            if last and tick() - last <= doubleTapWindow then
                doubleTapTimers[name] = nil
                bind.Callback({input=input, kind="DoubleTap"})
                InputManager.Log("DoubleTap", {name=name})
            else
                doubleTapTimers[name] = tick()
            end
        else
            bind.Callback({input=input, kind="Tap"})
            InputManager.Log("Tap", {name=name})
        end
    end
end

local function handleInputEnded(input)
    local ctx = topContext()
    for name, bind in pairs(InputManager.bindings) do
        if bind.Type == InputType.Hold and (bind.Context == ctx or bind.Context == "global") and (input.KeyCode == bind.Key or input.UserInputType == bind.Key) then
            local h = activeHolds[name]
            if h then
                local dur = tick() - h.start
                if dur >= bind.HoldThreshold then
                    bind.Callback({input=input, kind="Hold", duration=dur})
                    InputManager.Log("Hold", {name=name, duration=dur})
                end
                activeHolds[name] = nil
            end
        end
    end
end

local function processHoldRepeats()
    for name, h in pairs(activeHolds) do
        local bind = h.bind
        if bind.RepeatRate > 0 and tick() - h.lastRepeat >= bind.RepeatRate then
            bind.Callback({input=h.input, kind="HoldRepeat", duration=tick() - h.start})
            InputManager.Log("HoldRepeat", {name=name})
            h.lastRepeat = tick()
        end
    end
end

UserInputService.InputBegan:Connect(handleInputBegan)
UserInputService.InputEnded:Connect(handleInputEnded)
RunService.Heartbeat:Connect(processHoldRepeats)

function InputManager.Debug()
    for name, bind in pairs(InputManager.bindings) do
        print(string.format("[Input] %s → %s (%s) prio=%d type=%s", name, tostring(bind.Key), bind.Context, bind.Priority, bind.Type))
    end
end

return InputManager

Example Usage

local InputManager = require(path.To.InputManager)

-- Context example
InputManager.PushContext("gameplay")

-- Tap binding
InputManager.Register("Jump", {
    Key = Enum.KeyCode.Space,
    Type = "Tap",
    Priority = 10,
    Callback = function(ev)
        print("Jump triggered:", ev.kind)
    end
})

-- Hold binding with per-binding threshold + repeat
InputManager.Register("Sprint", {
    Key = Enum.KeyCode.LeftShift,
    Type = "Hold",
    Priority = 9,
    HoldThreshold = 0.5, -- custom threshold for Sprint
    RepeatRate = 0.5,    -- fires every 0.5s while held
    Callback = function(ev)
        if ev.kind == "Hold" then
            print("Sprint held for", ev.duration)
        elseif ev.kind == "HoldRepeat" then
            print("Sprint repeat tick")
        end
    end
})

-- DoubleTap binding
InputManager.Register("Dash", {
    Key = Enum.KeyCode.D,
    Type = "DoubleTap",
    Priority = 8,
    Callback = function(ev)
        print("Dash double tap!")
    end
})

-- Rebinding example
-- Start listening for a new key for "Jump"
InputManager.BeginRebind("Jump", {timeout = 5})

-- Later, when the player presses a new key, "Jump" will be rebound automatically

Context Behavior

  • Bindings with Context = "global" are always active.
  • Use contexts to separate gameplay, menus, cutscenes, etc.
  • Push/pop contexts to swap input layers dynamically.

Logging Example

-- Retrieve logs
for _, log in ipairs(InputManager.GetLogs()) do
    print(log.t, log.e, log.p)
end

-- Clear logs
InputManager.ClearLogs()