How to Detect Two Keys Pressed at the Same Time (NOT Holding One, Then the Other)

Context

Hey, I’m currently scripting a combat system that allows the player to swing in 3 different motions; High, Middle or Low (heavy inspiration from For Honor). I thought it would be easiest to have Middle Swing be Left Click (MouseButton1), Low Swing be Right Click (MouseButton2), and High Swing be Left & Right Click together. Since, obviously, a player will never press two buttons at the exact same time on the exact same frame, I’ve scripting a system that does the following.

  • Logs the first button you press (Middle or Low Swing; M1 or M2)
  • Sends that info to the Local Script (inside the weapon tool)
  • If another button is pressed within a 0.03 second window, send signal to swing High

The calculation to check if another button is pressed in a 0.03 window is as follows:

--this script is inside of a function that runs whenever the LocalScript sends the signal that the player pressed M1 or M2
--lastInputTime: the tick() of the input that was just pressed (M1 or M2)
--inputTime: the time of the FIRST input that was pressed (nil if you never pressed a button yet; get set back to nil when High Swing signal is sent)
--swingEvent: the Remote Event that sends the signal
    if lastInput == nil then
		lastInput = input
		lastInputTime = tick()
	else
local timeGap = lastInputTime - inputTime
		print(timeGap)
		lastInputTime = tick()
		if (timeGap * -1) <= 0.03 then
			tnputTime = nil
			warn("Swing High")
			swingEvent:FireClient(player, "HIGH") --Send "HIGH" signal to player; Swing High
		else
			swingEvent:FireClient(player, input) --Time Gap was too long; player only pressed one button; allow the player to swing Middle or Low without overriding.
	    end
     end

Example: If i press M1 then M2 in quick succession, the script will send the signal for Middle, then High. (lastInput is now “MIDDLE” (M1); inputTime is now the tick() that I pressed M2).

The Problem

There are several problems here:

  • The script will not read the very first input when you join the game (because lastInput is nil)
  • The script sends the signal to swing Middle or Low BEFORE sending the signal to swing High
  • The script cannot tell if the last input is the same button, so if a player uses an autoclicker or somehow manages to press the same button twice within 0.03 seconds the server will send the signal to swing high.

I thought a simple way to fix most of these issues would be to make the script wait 0.03 seconds before firing the first input, and if the other input is fired in that time, then swing High. The only problem is I have no idea how to do this. I’ve attempted it, but it just made it worse so I reverted the changes.

If anyone would like to review these, here they are:

Also if anything is unclear, please let me know.

TOOL SCRIPT:

local CAS = game:GetService("ContextActionService")
local UIS = game:GetService("UserInputService")

local repStor = game:GetService("ReplicatedStorage")
local attackEvent = repStor.AttackInput

local WEAPON_SWINGMIDDLE = "SwingMiddle"
local WEAPON_SWINGLOW = "SwingLow"
local WEAPON_SWINGHIGH	 = "SwingHigh"

local deciding = false

local tool = script.Parent
local swingingAtt = tool:GetAttribute("Swinging")

--HIGH SWING CHECK (M1 + M2) 
local function highSwingCheck(decision)
	print(decision)
	if decision == "LOW" then
		--PERFOM SWING CODE
		wait(1) --example swing time
		tool:SetAttribute("Swinging", false)
	elseif decision == "MIDDLE" then
		--PERFOM SWING CODE
		wait(1)
		tool:SetAttribute("Swinging", false)
	elseif decision == "HIGH" then
		--Perform High swing Code
		wait(1)
		tool:SetAttribute("Swinging", false)
	end
end

--[=========================================================================================================]
--SWING MIDDLE CODE

local function weaponSwingMiddle(actionName, inputState, _inputObject)
	if actionName == WEAPON_SWINGMIDDLE and inputState == Enum.UserInputState.Begin then
		tool:SetAttribute("Swinging", true)
		attackEvent:FireServer("MIDDLE", tick())
		attackEvent.OnClientEvent:Connect(highSwingCheck)
	end
end

tool.Equipped:Connect(function()
	CAS:BindAction(WEAPON_SWINGMIDDLE, weaponSwingMiddle, false, Enum.UserInputType.MouseButton1)
end)

tool.Unequipped:Connect(function()
	CAS:UnbindAction(WEAPON_SWINGMIDDLE)
end)

--[=========================================================================================================]
--SWING LOW CODE

local function weaponSwingLow(actionName, inputState, _inputObject)
	if actionName == WEAPON_SWINGLOW and inputState == Enum.UserInputState.Begin then
		tool:SetAttribute("Swinging", true)
		attackEvent:FireServer("LOW", tick())
		attackEvent.OnClientEvent:Connect(highSwingCheck)
	end
end

tool.Equipped:Connect(function()
	CAS:BindAction(WEAPON_SWINGLOW, weaponSwingLow, false, Enum.UserInputType.MouseButton2)
end)

tool.Unequipped:Connect(function()
	CAS:UnbindAction(WEAPON_SWINGLOW)
end)

SERVER SCRIPT:

local repStor = game:GetService("ReplicatedStorage")
local swingEvent = repStor.AttackInput --RemoteEvent

local lastInput = nil
local lastInputTime = nil
local waitingInput = nil


swingEvent.OnServerEvent:Connect(function(player, input, inputTime)
	if lastInput == nil then
		lastInput = input
		lastInputTime = tick()
	else
		local timeGap = lastInputTime - inputTime
		print(timeGap)
		lastInputTime = tick()
		if (timeGap * -1) <= 0.03 then
			waitingInput = nil
			warn("SWING HIGH")
			swingEvent:FireClient(player, "HIGH")
		else
			swingEvent:FireClient(player, input)
		end
	end	
end)

Or do I just bite the bullet and make High Swing a different button? :cry:

2 Likes

interesting question. you can maybe add a 0.05 second delay before the script register if it is a low swing . when it is a low swinging, it can check if it is also a mid swing

(Wrong reply)
Found something in the forums.

Either do what @yoshicoolTV suggested, or you could create a table that stores recently pressed keys that automatically remove the key after 0.03 seconds. Example:

local userInputService = game:GetService("UserInputService")

local recentlyPressedKeys = {}

local function inputBegan(input, processed)
    if processed then return end
    
    local pos = #recentlyPressedKeys + 1
    table.insert(recentlyPressedKeys, pos, input)
    
    local function removeKey()
        table.remove(recentlyPressedKeys, pos)
    end
    task.delay(0.03, removeKey)
    
    -- Do what you will with the inputs
end

userInputService.InputBegan:Connect(inputBegan)
1 Like

This doesn’t help; it only checks if they are being held down at any time which is not what I want. Reread the post.

This is interesting, but how can It be changed to perform the High Swing when the second input is pressed while the 0.03s ‘timer’ is still running?

Couldn’t you just change @yoshicoolTV’s reply a bit.

local UIS = game:GetService("UserInputService")
local HoldingFirstKey = false
UIS.InputBegan:Connect(function()
		
	--// This has to be above the Second part since the second part will set HoldingFirstKey back to false.
	
	if UIS:IsKeyDown(Enum.KeyCode.E) and HoldingFirstKey == true then
		print("Yeah, it works.")
	end
	
	--// Keep this one under the above one
	
	if UIS:IsKeyDown(Enum.KeyCode.Q) and not UIS:IsKeyDown(Enum.KeyCode.E) then
		HoldingFirstKey = true
	else
		HoldingFirstKey = false
	end
end)

You’ll have to hold Q before you can press E. Doing it the other way around wont work

Again, reread the post; this isn’t the problem. I don’t want to just check if two things are held down at the same time, i want an action to be bound to clicking them at literally the same time, kind of like a fighting game attack (or a double note press in a rhythm game), hence the 0.03 second time check.

Don’t bite the bullet, this is a very simple thing to fix. It’s very frustrating if you’re not used to it, this is more about logic than actual scripting.

You don’t really need to know the last key pressed or the last input stored within the last 0.03 seconds, what you really need to know is if the time between both keys being pressed is within 0.03 seconds.

First you need to log the last time either buttons were pressed. Then you can just compare those times, and if the gap is within 0.03 seconds, they were pressed at the same time.

Kinda like this:

local lastM1 = 0
local lastM2 = 0 
local KeyDown = {}

-- connect to InputBegan to log lastM1, lastM2 and also log KeyDown
UserInputService.InputBegan:Connect(function(input, gpe)
	-- ignore game processed event 
	if gpe then return end 
	
	-- get the time now 
	local now = os.clock()
	
	-- log the time M1 or M2 was pressed 
	local inputType = input.UserInputType
	if inputType == Enum.UserInputType.MouseButton1 then 
		lastM1 = now
		KeyDown["M1"] = true 
	elseif inputType == Enum.UserInputType.MouseButton2 then 
		lastM2 = now 
		KeyDown["M2"] = true
	end
	
	
	-- if the key pressed is either M1 or M2, check what attack needs to be done 
	if inputType == Enum.UserInputType.MouseButton1 or inputType == Enum.UserInputType.MouseButton2 then 
		local decision
		if KeyDown["M1"] and not KeyDown["M2"] then 
			-- only M1 pressed 
			decision = WEAPON_SWINGMIDDLE
		elseif KeyDown["M2"] and not KeyDown["M1"] then 
			-- only M2 pressed 
			decision = WEAPON_SWINGLOW 
		elseif KeyDown["M1"] and KeyDown["M2"] then 
			-- both pressed, check if same time 
			local timeGap = math.abs(lastM1 - lastM2)
			if timeGap <= 0.03 then 
				-- M1 and M2 pressed at the same time 
				decision = WEAPON_SWINGHIGH
			end
		end
		highSwingCheck(decision)
	end 
end)

-- connect to InputEnded to clear the KeyDown table 
UserInputService.InputEnded:Connect(function(input, gpe)
	if input.UserInputType == Enum.UserInputType.MouseButton1 then 
		KeyDown["M1"] = false
	elseif input.UserInputType == Enum.UserInputType.MouseButton2 then 
		KeyDown["M2"] = false
	end
end)

Now I made ^ that in like 5 mins, but I hope you get what I’m doing here. Of course you can’t just copy and paste the code into your own, I just thought example code would be the easiest way to show you what I mean. Feel free to ask if there’s something you still don’t get about it.

I’m currently outside and this script seems to be what I’m looking for; it solves the issue of not being able to read the first input upon joining the game, and solves the issue of not being able to detect the same button, however will it still send a signal to perform “Middle” or “Low” before checking to swing high?

The image below is me clicking both buttons at once; the warning is the time the server script sends the input, and the regular text is the time the local script (inside the tool) makes the decision.
Screenshot 2023-11-12

Ah, good catch. To avoid that, you’d need to wait until the detection time has passed, then decide what the attack is going to be.

This is the caveat for this kind of input system, you’d need to test your detection time a lot to make sure it’s reasonable. Too low and it might be impossible, too high and there would be noticeable input lag (since you have to wait if the other button would be pressed within the detection time before actually performing any actions)

local lastM1 = 0
local lastM2 = 0 
local DETECTION_TIME = 0.03 -- test this a lot and come up with a more convenient value
-- too low and it might be impossible to detect a "same time" input 
-- too high and there would be noticeable input lag, that's the tradeoff for this kind of system  

local KeyDown = {}

-- connect to InputBegan to log lastM1, lastM2 and also log KeyDown
UserInputService.InputBegan:Connect(function(input, gpe)
	-- ignore game processed event 
	if gpe then return end 

	-- get the time now 
	local now = os.clock()

	-- if the key pressed is either M1 or M2, check what attack needs to be done 
	if inputType == Enum.UserInputType.MouseButton1 or inputType == Enum.UserInputType.MouseButton2 then 
		local decision
		if KeyDown["M1"] and not KeyDown["M2"] then 
			-- only M1 is pressed, but we're not sure if M2 would be pressed within detection time
			-- wait after DETECTION_TIME is over before deciding 
			-- we do this on a separate thread so we don't block the logging behavior
			task.delay(DETECTION_TIME, function()
				-- we do the check again, and if M2 is still not down then we can safely say there is 
				-- 	no intention to press M2 since the detection time has passed
				-- if M2 WAS pressed within detection time, the other thread would handle (override) it and this one would just end with no action (cancel) 
				if KeyDown["M1"] and not KeyDown["M2"] then
					decision = WEAPON_SWINGMIDDLE
				end
			end)
		elseif KeyDown["M2"] and not KeyDown["M1"] and (now - lastM1) > DETECTION_TIME then 
			-- only M2 is pressed, do the same thing
			task.delay(DETECTION_TIME, function()
				if KeyDown["M2"] and not KeyDown["M1"] then
					decision = WEAPON_SWINGLOW
				end
			end)
		elseif KeyDown["M1"] and KeyDown["M2"] then 
			-- both pressed, check if within detection time to determine if they were pressed "at the same time"
			-- we're not sure if the current button pressed down is M1 or M2, so just get the smaller time gap
			local timeGap = math.min(math.abs(now - lastM1), math.abs(now - lastM2))
			-- check if the gap is shorter than the detection time
			if timeGap <= DETECTION_TIME then 
				-- M1 and M2 pressed at the same time 
				decision = WEAPON_SWINGHIGH
			end
		else 
			-- I'm not sure how you'd end up here, but I think there shouldn't be any reason to? idk lol
		end
		highSwingCheck(decision)
	end 
	
	-- log the time M1 or M2 was pressed 
	local inputType = input.UserInputType
	if inputType == Enum.UserInputType.MouseButton1 then 
		lastM1 = now
		KeyDown["M1"] = true 
	elseif inputType == Enum.UserInputType.MouseButton2 then 
		lastM2 = now 
		KeyDown["M2"] = true
	end
end)

-- connect to InputEnded to clear the KeyDown table 
-- normally :IsKeyDown(KeyCode) would be sufficient, but that only works for keycode stuff so you have to do this for mouse events since they return Enum.Keycode.Unknown (I think)
UserInputService.InputEnded:Connect(function(input, gpe)
	if input.UserInputType == Enum.UserInputType.MouseButton1 then 
		KeyDown["M1"] = false
	elseif input.UserInputType == Enum.UserInputType.MouseButton2 then 
		KeyDown["M2"] = false
	end
end)

I’m a bit sleepy, so I’m not sure if the code is 100% bug proof. I did have to rearrange it so that the logging happens at the end of the frame to avoid more goofy behavior.

Wow, the idea of this code is honestly everything that I’m looking for, I’ve only got one question that may or may not just be my general Roblox knowledge.

Firstly, when you set lastM1 or lastM2 to the variable now, won’t it set the M1 time to the time that was already set in the beginning of the function, or is that the goal?

^ Also yes, mouse events fall under Enum.UserInputType, and give Unknown when read with Enum.Keycode, just like how if you want to log a keyboard input under Enum.UserInputType, it will simply return Enum.UserInputType.Keyboard.

I came up with a proof-of-concept version of this but it’s pretty jank. I’ll post it in case it leads you to anything but I don’t recommend using it.

local UserInputService = game:GetService("UserInputService")

local ACCEPTED_INPUTS = {
	[Enum.UserInputType.MouseButton1] = true,
	[Enum.UserInputType.MouseButton2] = true
}

local queuedInputs = {}
local currentQueueId = 0

local function readInputs(queueId)
	if currentQueueId > queueId then return end
	print(#queuedInputs == 2, currentQueueId, table.unpack(queuedInputs))
	table.clear(queuedInputs)
end

local function queueInput(inputType)
	currentQueueId += 1
	table.insert(queuedInputs, inputType)
	task.defer(readInputs, currentQueueId)
end

UserInputService.InputBegan:Connect(function (inputObject)
	if ACCEPTED_INPUTS[inputObject.UserInputType] then
		task.delay(0, queueInput, inputObject.UserInputType)
	end
end)

Besides the fact that it is jank, you’re right that it’s unrealistic for two keys to be pressed in one frame the way you want, and it might also not particularly intuitive for the player as opposed to holding down a modifier key before acting or (to a lesser extent, in my opinion) using the mouse’s last delta directions to determine the swing range such as in Blood and Iron/Guts & Blackpowder.

1 Like

Yep. I’m used to taking the timestamp at the beginning of the frame, that’s all. But it will process with whatever lastM1 or lastM2 was last set to (for example, the timestamp of the previous frame) then in the end when it’s all done, that’s when the lastM1 or lastM2 are updated with the latest timestamp, which is now.

You would add something like this in the inputBegan function, right below all the recentlyPressedKeys logic.

if table.find(recentlyPressedKeys, Enum.UserInputType.MouseButton1) and table.find(recentlyPressedKeys, Enum.UserInputType.MouseButton2) then
    -- High swing
end

task.delay() doesn’t yield, so you don’t really have to worry about doing anything within the time frame of the “timer”.

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.