buttonDown sometimes triggers from the wrong inputHandler after a "chord"

I have an inputHandler that responds only to buttonDown events.

In the buttonDown function, I check buttonIsPressed to see if a chord with another button is happening, and take an alternate action if so.

Here's what SHOULD happen when you press both (A) and (B) together:

First, either (A) or (B) buttonDown triggers (whichever happened to be pressed first in the chord). Its "solo" action executes.

Then, the other buttonDown triggers, but since it detects the other "buttonIsPressed", it performs the chord action instead of its "solo" action.

The bug happens when the chord action entails a change of inputHandler (like switching to a different game mode).

It also seems to happen only when the two buttons were pressed in the same frame. (Which is very easy even at 30fps, if you hit them near-simultaneously.)

In that situation, the first button's solo buttonDown is HELD OVER and executed later, on the wrong inputHandler.

The first solo action fails to trigger. (So be it: that frame has passed. It lost its chance.) Instead, that buttonDown is sent to the NEW InputHandler, causing unwanted behavior there.

This feels wrong: the inputHandler change can only be triggered by a chord event, so the button HAD to be down before the inputHandler change. No button is pressed down AFTER that inputHandler change, so no buttonDown should be received.

Proposed better outcome: any stray buttonDown events from before the handler change should be discarded. Otherwise, chords can be unpredictable.

I already have my own workaround for discarding "held over" buttonDown events. (I wait for a button UP before allowing any further button actions.) But in a faster-paced game than mine, that could have its own side effects.

Simple example project:
InputHandler Bug Test.zip (12.5 KB)

Here's the main.lua code from that:

local pd <const> = playdate

pd.display.setRefreshRate(10)

print("Modes 'Primary' and 'Secondary' have their own inputHandlers.")
print("(A) and (B) have unique buttonDown actions for each mode.")
print("Press BOTH buttons together to swap between the two modes.")
print("(No action is ever assigned to buttonUp.)\n")

print("TEST #1 - ALWAYS SUCCEEDS: \n")
print("Press either one, WAIT briefly holding it, then add the other.")
print("   DESIRED RESULT:\nThe first-pressed button's action will trigger, then\nthe mode will swap as the second button is added.\n")
	
print("TEST #2 - OFTEN FAILS (ESPECIALLY WITH LOW FPS): \n")
print("Press both buttons NEAR-SIMULTANEOUSLY.")
print("   SAME DESIRED RESULT: \nThe first-pressed button's action should trigger, then\nthe mode should immediately swap as the second button\nis added.")
print("   BUT OFTEN, INSTEAD: \nThe first button press, which occured in the intial mode\nand is required to trigger the swap, is IGNORED and instead\nis processed AFTER the mode swap. It is then treated as the\nsecond-pressed, which in turn triggers a second double-button\nmode swap. Thus, not only does the initial buttonDown action\nfail to happen, but the mode gets swapped twice, resulting in\nno mode change for the user.")

local modePrimaryInputHandlers = {
	BButtonDown = function()
		if pd.buttonIsPressed(pd.kButtonA) then --Both buttons together
			print("(A)+(B): swapping to Secondary mode...")
			startModeSecondary()
		else
			print("(B) pressed --> execute Primary mode B action")
		end
	end,
	
	AButtonDown = function()
		if pd.buttonIsPressed(pd.kButtonB) then --Both buttons together
			print("(B)+(A): swapping to Secondary mode...")
			startModeSecondary()
		else
			print("(A) pressed --> execute Primary mode A action")
		end
	end
}

local modeSecondaryInputHandlers = {
	BButtonDown = function()
		if pd.buttonIsPressed(pd.kButtonA) then --Both buttons together
			print("(A)+(B): swapping to Primary mode...")
			startModePrimary()
		else
			print("(B) pressed --> execute Secondary mode B action")
		end
	end,
	
	AButtonDown = function()
		if pd.buttonIsPressed(pd.kButtonB) then --Both buttons together
			print("(B)+(A): swapping to Primary mode...")
			startModePrimary()
		else
			print("(A) pressed --> execute Secondary mode A action")
		end
	end
}

function startModePrimary()
	print("\n   Now in mode PRIMARY")
	pd.inputHandlers.push(modePrimaryInputHandlers, true)
end

function startModeSecondary()
	print("\n   Now in mode SECONDARY")
	pd.inputHandlers.push(modeSecondaryInputHandlers, true)
end

function pd.update()
end

startModePrimary() --Begin in Primary mode

I tried the example and to me it behaves exactly as expected but I also see what is your expectation.

The key is that buttonIsPressed() is telling you what button are pressed at this exact moment. This returns the information as close to the hardware as possible and I believe this is refreshed 1000 times per seconds. The function doesn't take into account button press order as if it was a queue.

So before the playdate.update() the firmware is checking the button state and list the button that has been pressed or released compared to the previous update. From there it just call the correct callbacks.
My recommendation would be to not detect chords in the input callbacks but directly in the update functions to make sure it is called only once. In general I tend to prefer to avoid input callbacks because I feel this is cleaner to write everything in the update function.

local chord = playdate.kButtonA | playdate.kButtonB
if (playdate.getButtonState()&chord)==chord and (playdate.buttonJustPressed(playdate.kButtonA) or playdate.buttonJustPressed(playdate.kButtonB)) then
   -- Switch mode
end
1 Like

Thanks. update() is a workaround I may use. For now, I'm trying to keep my update() super lean, and my existing workaround is doing the trick. Quite awkwardly (counting buttonUps !) but not running all the time.

Plus, doing it in the inputHandler means I can directly ignore (or revert) the SOLO function(s) of the chorded buttons.

You could also implement something along the line the behaviour you were expecting.
You just need to track buttons that are pressed down or released.

local currentChord = 0
function chordButtonDown(button)
	currentChord = currentChord | button
end
function chordButtonUp(button)
	currentChord = currentChord & (~button)
end
function chordIsPressed(buttons)
	return (currentChord&buttons)==buttons
end

local myInputHandlers = {
	AButtonDown = function()
		chordButtonDown(pd.kButtonA)

		if chordIsPressed(pd.kButtonA | pd.kButtonB) then
			startModePrimary()
		end
	end,

	AButtonUp = function()
		chordButtonUp(pd.kButtonA)
	end,

	BButtonDown = function()
		chordButtonDown(pd.kButtonB)

		if chordIsPressed(pd.kButtonA | pd.kButtonB) then
			startModePrimary()
		end
	end,

	BButtonUp = function()
		chordButtonUp(pd.kButtonB)
	end
}
1 Like

I like those bitwise operators! They seem nearly undocumented in Lua. Good to keep in mind.

It's tricky when the buttonUp takes place in the new mode's different inputHandler. But I pass a variable that tracks it.