2D Fighting Game Input Buffer

Hello Everyone, What is the best way to create a fighting game 2D input buffer?

I’ve already attempted making one which looks like this

-- Constant Variables

local Player: Player = game.Players.LocalPlayer
local Character: Model = Player.Character or Player.CharacterAdded:Wait()
local Humanoid: Humanoid = Character:WaitForChild('Humanoid')
local HumanoidRootPart: Part = Character:WaitForChild('HumanoidRootPart')
local Metadata: Folder = Player:WaitForChild('Metadata')
local StateValue: StringValue = Metadata:WaitForChild('State')
local Animator: Animator

-- Prequisites.

if Humanoid:FindFirstChild("Animator") then
	Animator = Humanoid:FindFirstChild("Animator")
else
	Animator = Instance.new('Animator', Humanoid)
end

-- Test Dataset

local moveSet = {
	["1"] = {
		["Initial"] = {
			['Animation'] = '14465067787',
			['VelocityForwards'] = 1,
			['DirectionAhead'] = 2.5,
			['HitboxSize'] = Vector3.new(5,5,5),
			['KnockBackForce'] = 500 -- Keep relatively small like around 1000
		},
		["23"] = "Right Hook",
		["32"] = "Kick",
		["11"] = "Headbutt",
		["22"] = "DoublePunch"
	},
	['2'] = {

	},
	['3'] = {

	}
}

-- Requires

local Polarix = require(game:GetService("ReplicatedStorage").Polarix).new()
local Keyboard = require(game:GetService("ReplicatedStorage").Polarix.Modules.Util.Keyboard).new()
local Trove = require(game:GetService("ReplicatedStorage").Polarix.Modules.Util.Trove).new()
local Boost = require(game:GetService("ReplicatedStorage").Polarix.Modules.Global.Boost)

-- Runtime Variables

local inputTable = {}
local lastInputTime = 0
local comboString = ""
local comboMatch
local canAttack = false

-- Function to translate keybinds to universal numeric inputs
local function TranslateKeyCode(input)
	local keyReturnTable = {
		[Enum.KeyCode.H] = "1",
		[Enum.KeyCode.J] = "2",
		[Enum.KeyCode.K] = "3"
	}
	return keyReturnTable[input]
end

-- Function to clear inputTable if no inputs within the last 3 seconds

local function ClearInputTableIfIdle()
	if tick() - lastInputTime > 0.3 then
		inputTable = {}
	end
end


-- Function to get an animationTrack to be playable via Animator.
-- Parameters: ID
-- Returns: AnimationTrack

local function getPlayableAnimation(animationId: string)

	local AnimationToPlay = Instance.new('Animation')
	AnimationToPlay.AnimationId = "rbxassetid://"..animationId
	local PlayableAnimation = Animator:LoadAnimation(AnimationToPlay)

	Trove:Add(PlayableAnimation)
	Trove:Add(AnimationToPlay)

	return PlayableAnimation

end


-- Main Code Execution

Keyboard.KeyDown:Connect(function(key)
	local numericInput = TranslateKeyCode(key)

	if numericInput then
		lastInputTime = tick() -- Update last input time
		table.insert(inputTable, numericInput)
		comboString = tostring(table.concat(inputTable))

		print("ComboString:", comboString)

		local lastIndex = string.len(comboString) - 1
		local lastChar = string.sub(comboString, lastIndex, lastIndex)
		local secondLastChar = string.sub(comboString, lastIndex - 1, lastIndex - 1)..lastChar 
		local thirdLastChar = secondLastChar..string.sub(comboString, lastIndex - 2, lastIndex - 2)

		print("LastCharacter:", lastChar)
		print("SecondLastCharacter:", secondLastChar)
		print("ThirdLastCharacter:", thirdLastChar)

		print("CurrentPlayerState:", StateValue.Value)

		if StateValue.Value ~= "ForwardDash" or StateValue.Value ~= "BackwardDash" then
			if lastChar == "" then

				local Data:{} = moveSet[numericInput]["Initial"]
				local AnimationId: string = Data.Animation
				local Velocity: number = Data.VelocityForwards
				local DirectionAhead: number = Data.DirectionAhead
				local HitboxSize: Vector3 = Data.HitboxSize
				local KnockBackForce: number = Data.KnockBackForce

				local AnimationToPlay = Instance.new('Animation')
				AnimationToPlay.AnimationId = "rbxassetid://"..AnimationId
				local PlayableAnimation = Animator:LoadAnimation(AnimationToPlay)

				Trove:Add(PlayableAnimation)
				Trove:Add(AnimationToPlay)

				PlayableAnimation.KeyframeReached:Connect(function(keyframeName: string) 
					
					print(keyframeName)
					
					if keyframeName == "RegisterHit" then
						Boost.AddVelocity(HumanoidRootPart, 600, 0.1)
						Polarix:FireMultiplePackets("CreateInputHitbox", {HumanoidRootPart, DirectionAhead, HitboxSize, KnockBackForce})
					end
				end)
				
				PlayableAnimation:Play()
				
				
				Trove:Connect(PlayableAnimation.Stopped, function()
					Trove:Clean()
				end)
			end

			if string.len(comboString) >= 3 then
				if moveSet[numericInput][secondLastChar] then
					print(moveSet[numericInput][secondLastChar])
				end
			end
		end		
	end
end)

-- Connect a timer to periodically check and clear inputTable if idle
local checkIdleTimer = game:GetService("RunService").Heartbeat:Connect(ClearInputTableIfIdle)

As you can tell it’s pretty janky and pretty flawed, If anyone can tell me any methods, structures, or preferably articles I can take a look at, it would be much appreciated!

7 Likes

Did you figure this out?

charCharacterLimit40

1 Like

I won’t send FULL scripts, but I’ll send some important snippets.

1: Create three variables:

local inputs = {}
local currentFrame: number = 0
local frameAtInput: number = 0

2: Create your moveset like this:

	['Hadoken'] = {
		Sequences = {{"Down","Forward","Punch"}}, -- IDK if this is the actual inputs lol
		TimeWindow = 15,
		Priority = 5,
		Activation = function() 
           print("Doing Hadoken")
		end,
	},

(you should be able to translate roblox’s input keycodes into those strings, ie: Enum.KeyCode.D = “Forward”)
3: Using RunService, create this function

RunService.RenderStepped:Connect(function(deltaTime: number) 
	if (currentFrame - frameAtInput) >= 15 then
		if #inputBuffer >= 1 then
			
			local nameOfInputs: {string} = {}			
			local validMoves: {} = {}
			local howLong: number = (inputBuffer[#inputBuffer][2]-inputBuffer[1][2])
			
			for _, inputData in pairs(inputBuffer) do
				table.insert(nameOfInputs, inputData[1])
			end
									
			for name, moveData: {Sequences: {string}, Priority: number} in pairs(moveset) do
				for _, sequences: {} in pairs(moveData.Sequences) do
					if InputProcessor:Search(nameOfInputs, sequences) then
						table.insert(validMoves, moveData.Priority)
					end
				end
			end		
			
			
			if #validMoves >= 1 then
				
				local highestPriorityMove = InputProcessor:FindMoveByPriority(moveset, InputProcessor:FindMaxValue(validMoves))
				local lowestPriorityMove = InputProcessor:FindMoveByPriority(moveset, InputProcessor:FindMinValue(validMoves))
				
			
				if highestPriorityMove.TimeWindow >= howLong then
					highestPriorityMove.Activation()
				else
					lowestPriorityMove.Activation()
				end
				
			end
			
			inputBuffer = {}
			frameAtInput = currentFrame
		end
	end
	currentFrame += 1
end)

Just use chatGPT and ask "How to see if a pattern like {“Up,“Forward”} exists in a sequence of inputs such as {“Forward”,“Up”,“Forward”}. Write me an algorithm in roblox lua that achieves this.”

4: Bind it together using UIS

UIS.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean)
	if gameProcessedEvent then return end
	if input.UserInputType ~= Enum.UserInputType.Keyboard then return end
	if Translation:Translate(input) then
		frameAtInput = currentFrame
		table.insert(inputBuffer, {Translation:Translate(input), frameAtInput})
	end	
end)
2 Likes

Okay I think i understand your code, i’ve used your code as reference to create a combat system too, and the only problem i’m having is the animations playing too fast when the inputs are being pressed rapidly. Because in fighting games, we want the input to be pressed rapidly so the player can do their attacks. But the animations is what I dont understand.

Example in this code snipped:
–Module script:
[“I”] = {

	Action = function(Humanoid)

		local animator = Humanoid:WaitForChild("Animator")
		currentAnimationTrack = animator:LoadAnimation(M1)
		currentAnimationTrack:Play()
		
		

		print("Hit 1!")



	end, -- First Hit


},

["I_I"] = {
	Action = function(Humanoid)

		local animator = Humanoid:WaitForChild("Animator")
		currentAnimationTrack = animator:LoadAnimation(M2)
		currentAnimationTrack:Play()
		
	
		print("2 hit combo!")
	end,
},

["I_I_I"] = {
	Action = function(Humanoid)

		local animator = Humanoid:WaitForChild("Animator")
		currentAnimationTrack = animator:LoadAnimation(M3)
		currentAnimationTrack:Play()
		
		
		print("3 hit combo!")
	end,
},

and my output looks something like this:

21:17:22.366 I - Client - BattleControl:53
21:17:22.366 Hit 1! - Client - CombatEvents:38
21:17:22.498 Players current input is: I - Client - BattleControl:39
21:17:22.516 I_I - Client - BattleControl:53
21:17:22.516 2 hit combo! - Client - CombatEvents:55
21:17:22.614 Players current input is: I - Client - BattleControl:39
21:17:22.632 I_I_I - Client - BattleControl:53
21:17:22.632 3 hit combo! - Client - CombatEvents:67
21:17:22.764 Players current input is: I - Client - BattleControl:39
21:17:22.783 I_I_I_I - Client - BattleControl:53
here’s a video:

I’m kinda confused at the issue, I get that there is one but yk

What I think is happening: Your animations are playing too fast or ahead of time when the player is still inputting commands?

If so you can do like

for _, animationTracks: AnimationTrack in pairs(animator:GetPlayingAnimationTracks()) do
   animationTracks:Stop()
end

To reset whatever animation is playing. And make sure during the input processing you add a debounce of some sorts. (it’s going to feel sluggish at first, but you can add an input buffer)

1 Like

So if one animation track is playing it’ll stop that one and play the other one on the next input? I’ll have to research how input buffer works to implent that if thats the case.

ikk Idk how to explain the issue because if i spam the key in half a second since thats the time it takes before the table resets itself, the animations plays all crazy.

nah cuz inputting a 4 hit combo the previous inputs prints at the same time as the print statement “Hit” prints"
example here:

21:30:17.156 I - Client - BattleControl:53
21:30:17.156 Hit 1! - Client - CombatEvents:38

1 Like

Yes, (it’s better if you do this anyways so it doesn’t look like your animations are all blending together)
And an input buffer by definition is “A period of time where a fighting game will accept the input for an attack” (esentially queuing the next attack while the current one is still in motion) BUT DO THIS FOR THE DEBOUNCE.

Yeah this probably will be an issue of js resetting the animations. Since they’re just one key inputs for the processor to pattern match with (which is what I’m assuming you did), it’s very easy for the animations to mix.

It looks like it’s only printing the first hit?

Yeah then if the player presses another key it’ll print 2 hit combo!. and so on.

Okay then so the plan I thought of is to run input buffers whenever a key is pressed, if the player spams 4 hits within 0.5 then the input buffer scheduler will handle it and it’ll play each input corresponding to the table after each animation is finished. Solid plan?

Isn’t this a good thing? Sorry maybe I’m not understanding

1 Like

Not whenever a key is pressed but whenever a correct move is executed.

For a single input buffer, you can try something like this:

  • Create two variables: BufferOpen and BufferInput.
  • A few frames before your current move ends, set BufferOpen to true.
  • While BufferOpen is true, store the next attempted move in BufferInput.
  • Once the current move ends, set BufferOpen to false.
  • Translate BufferInput into the next move and execute it.
1 Like

This should be right after the buffer input ends or during the process loop?

RunService.RenderStepped:Connect(function(deltaTime: number) 
	if (currentFrame - frameAtInput) >= 15 then
		if #inputBuffer >= 1 then
			
			local nameOfInputs: {string} = {}			
			local validMoves: {} = {}
			local howLong: number = (inputBuffer[#inputBuffer][2]-inputBuffer[1][2])
			
			for _, inputData in pairs(inputBuffer) do
				table.insert(nameOfInputs, inputData[1])
			end
									
			for name, moveData: {Sequences: {string}, Priority: number} in pairs(moveset) do
				for _, sequences: {} in pairs(moveData.Sequences) do
					if InputProcessor:Search(nameOfInputs, sequences) then
						table.insert(validMoves, moveData.Priority)
					end
				end
			end		
			
			
			if #validMoves >= 1 then
				
				local highestPriorityMove = InputProcessor:FindMoveByPriority(moveset, InputProcessor:FindMaxValue(validMoves))
				local lowestPriorityMove = InputProcessor:FindMoveByPriority(moveset, InputProcessor:FindMinValue(validMoves))
				
			
				if highestPriorityMove.TimeWindow >= howLong then
					highestPriorityMove.Activation()
				else
					lowestPriorityMove.Activation()
				end
				
			end
			
			inputBuffer = {}
			frameAtInput = currentFrame
		end
	end
	currentFrame += 1
end)

Typed this up, enjoy.


-- Constants
local Player: Player = game.Players.LocalPlayer
local Character: Model = Player.Character or Player.CharacterAdded:Wait()
local Humanoid: Humanoid = Character:WaitForChild('Humanoid')
local HumanoidRootPart: Part = Character:WaitForChild('HumanoidRootPart')
local Metadata: Folder = Player:WaitForChild('Metadata')
local StateValue: StringValue = Metadata:WaitForChild('State')
local Animator: Animator = Humanoid:FindFirstChild("Animator") or Instance.new('Animator', Humanoid)

-- Test Dataset
local moveSet = {
    ["1"] = {
        ["Initial"] = {
            ['Animation'] = '14465067787',
            ['VelocityForwards'] = 1,
            ['DirectionAhead'] = 2.5,
            ['HitboxSize'] = Vector3.new(5,5,5),
            ['KnockBackForce'] = 500
        },
        ["23"] = "Right Hook",
        ["32"] = "Kick",
        ["11"] = "Headbutt",
        ["22"] = "DoublePunch"
    },
    ['2'] = {},
    ['3'] = {}
}

-- Requires
local Polarix = require(game:GetService("ReplicatedStorage").Polarix).new()
local Keyboard = require(game:GetService("ReplicatedStorage").Polarix.Modules.Util.Keyboard).new()
local Trove = require(game:GetService("ReplicatedStorage").Polarix.Modules.Util.Trove).new()
local Boost = require(game:GetService("ReplicatedStorage").Polarix.Modules.Global.Boost)

-- Interfaces
local IInputHandler = {}
IInputHandler.__index = IInputHandler

function IInputHandler.new()
    local self = setmetatable({}, IInputHandler)
    self.inputBuffer = {}
    self.lastInputTime = 0
    self.comboString = ""
    self.comboCache = {}
    self.animationCache = {}
    return self
end

function IInputHandler:TranslateKeyCode(input: Enum.KeyCode): string?
    local keyReturnTable = {
        [Enum.KeyCode.H] = "1",
        [Enum.KeyCode.J] = "2",
        [Enum.KeyCode.K] = "3"
    }
    return keyReturnTable[input]
end

function IInputHandler:ClearInputBufferIfIdle()
    if tick() - self.lastInputTime > 0.3 then
        self.inputBuffer = {}
        self.comboString = ""
        self.comboCache = {}
    end
end

function IInputHandler:HandleInput(numericInput: string)
    self.lastInputTime = tick()
    table.insert(self.inputBuffer, numericInput)
    self.comboString = table.concat(self.inputBuffer)

    -- Check for combos using cached results
    for i = #self.comboString, 1, -1 do
        local subCombo = string.sub(self.comboString, i)
        if not self.comboCache[subCombo] then
            self.comboCache[subCombo] = moveSet[numericInput][subCombo]
        end
        if self.comboCache[subCombo] then
            print("Combo Found:", self.comboCache[subCombo])
            -- Execute the combo action here
            break
        end
    end

    if StateValue.Value ~= "ForwardDash" and StateValue.Value ~= "BackwardDash" then
        local initialMove = moveSet[numericInput]["Initial"]
        if initialMove then
            local PlayableAnimation = self:getPlayableAnimation(initialMove.Animation)
            PlayableAnimation.KeyframeReached:Connect(function(keyframeName: string)
                if keyframeName == "RegisterHit" then
                    Boost.AddVelocity(HumanoidRootPart, 600, 0.1)
                    Polarix:FireMultiplePackets("CreateInputHitbox", {HumanoidRootPart, initialMove.DirectionAhead, initialMove.HitboxSize, initialMove.KnockBackForce})
                end
            end)
            PlayableAnimation:Play()
        end
    end
end

function IInputHandler:getPlayableAnimation(animationId: string): AnimationTrack
    if not self.animationCache[animationId] then
        local AnimationToPlay = Instance.new('Animation')
        AnimationToPlay.AnimationId = "rbxassetid://"..animationId
        local PlayableAnimation = Animator:LoadAnimation(AnimationToPlay)
        Trove:Add(PlayableAnimation)
        Trove:Add(AnimationToPlay)
        self.animationCache[animationId] = PlayableAnimation
    end
    return self.animationCache[animationId]
end

-- Main Code Execution
local inputHandler = IInputHandler.new()

Keyboard.KeyDown:Connect(function(key: Enum.KeyCode)
    local numericInput = inputHandler:TranslateKeyCode(key)
    if numericInput then
        inputHandler:HandleInput(numericInput)
    end
end)

-- Connect a timer to periodically check and clear inputBuffer if idle
local checkIdleTimer = game:GetService("RunService").Heartbeat:Connect(function()
    inputHandler:ClearInputBufferIfIdle()
end)

Executing the next move should happen after the buffer closes.
I made a chart to try and explain it better:

+-----------------------------------------------------------------------------------------------------------------------------+
|                                                                                                                             |
| +-------------------+--------------------+-------------------+------------------+--------------------+--------------------+ |
| | Move begins       | Wait till move     | Open Buffer       | Wait till move   | Move ends          | Turn input into    | |
+->                   | is almost over     |                   | ends             | Close buffer       | corresponding move +-^
  |                   |                    |                   |                  |                    | and execute it     |
  +-------------------+--------------------+-------------------+---------+--------+--------------------+--------------------+
                                                                         |
                                                    +--------------------+-------------------+
                                                    | While waiting for move to end, capture |
                                                    | any input and store it in the buffer   |
                                                    +----------------------------------------+
1 Like

It is a good thing I was just describing what the print statements do when the player presses a certain key.

im laughing so hard right now, I found a solution for the animations. I added this line of code here

elseif actionName == "Attack" and inputState == Enum.UserInputState.Begin and tick() - LastM1 > .4  then

and create a variable called LastM1 and set it to zero. So LastM1 is the player last input according to tick, then we compare and see if lastM1 and tick is greater than .4 seconds.

1 Like

What did you use to make this chart lol