Where to start when making an input buffer system?

On this, there is no event.is_action_just_pressed() in InputEvent. How else would I detect a multi-frame input this way..? Does event.is_action_released() work here? Isn’t there a major risk of an input sticking, this way?

That’s not an engine bug. That’s an architecture bug. You just need to use Godot correctly. One solution is you can detect the keys separately. A second solution is using actions like this:

if event.is_action_pressed("walk", false, true):
	walk()
elif event.is_action_pressed("run", false, true):
	walk()

That true as the third argument says to match the action exactly with modifiers, and that would fix your bug.

You can still use Input.is_action_just_pressed() inside _input() and _unhandled_input(). You can do the same with Input.is_action_just_released()

So, for today’s evening update:

I got every thing I was looking for and implemented it, except for the very first one that got answered here.

using @KerimCetinbasanswer, I was able to make a very robust input system that minimizes its processor usage as much as I could manage, and still buffers far more than just a single input. Changes to his code:

class_name BufferedAction extends RefCounted

var time_held: float = 0.0

var kind: StringName
var time_left: float
var buffer_time: float
var held: bool
var holdable: bool

func _init(_kind: StringName, _window: float, _held: bool, _holdable: bool) -> void:
	kind = _kind
	time_left = _window
	buffer_time = _window
	held = _held
	holdable = _holdable
class_name ActionBuffer
extends RefCounted

var actions: Array[BufferedAction] = []

func push(kind: StringName, window: float, held: bool, holdable: bool) -> void:
	# dedupe: if it's already buffered, just refresh the timer
	for action in actions:
		if action.kind == kind:
			if held and holdable:
				action.time_left = window
			action.held = held
			#return
	actions.append(BufferedAction.new(kind, window, held, holdable))


# success path: find + remove
func try_consume(kind: StringName) -> bool:
	for action_index in range(actions.size()):
		if actions[action_index].kind == kind:
			if not actions[action_index].held or not actions[action_index].holdable:
				actions.remove_at(action_index)
			return true
	return false


func try_purge(kind: StringName) -> void:
	for action_index in range(actions.size()):
		if actions[action_index].kind == kind:
			if actions[action_index].held and actions[action_index].time_held >= actions[action_index].buffer_time:
				actions.remove_at(action_index)


# just remove expired ones here
func tick(delta: float) -> void:
	for action_index in range(actions.size() - 1, -1, -1):
		actions[action_index].time_left -= delta
		actions[action_index].time_held += delta
		if actions[action_index].time_left <= 0.0 and not actions[action_index].held:
			actions.remove_at(action_index)


# if we need fully clear the buffer
func flush() -> void:
	actions.clear()

class_name Player extends CharacterBody2D

@export var InputBufferTypes: Dictionary[String, Dictionary] = {
	"GP_JUMP": {"input_buffer": 0.1, "holdable": true},
	#"GP_LEFT": {"input_buffer": 0.0, "holdable": true},
	"GP_DOWN": {"input_buffer": 0.1, "holdable": false},
	#GP_RIGHT: {"input_buffer": 0.0, "holdable": true},
	"GP_RUN": {"input_buffer": 0.0, "holdable": true},
	"GP_LIGHT_ATTACK": {"input_buffer": 0.15, "holdable": false},
	"GP_HEAVY_ATTACK": {"input_buffer": 0.15, "holdable": false},
}

@onready var input_buffer: ActionBuffer = ActionBuffer.new()

func _unhandled_input(event: InputEvent) -> void:
	for input_type in InputBufferTypes.keys():
		if event.is_action_pressed(input_type.to_lower()):
			input_buffer.push(input_type.to_lower(), InputBufferTypes[input_type].input_buffer, true, InputBufferTypes[input_type].holdable)
		elif event.is_action_released(input_type.to_lower()):
			input_buffer.push(input_type.to_lower(), InputBufferTypes[input_type].input_buffer, false, InputBufferTypes[input_type].holdable)
			input_buffer.try_purge(input_type)

Plus, on a state machine:

class_name AttackState extends State

@export var attack_name: String = "Attack"
@export var combo_timeout_time: float = 1.0

@onready var light_attack_child_state: State = get_node_or_null("LightAttack")
@onready var heavy_attack_child_state: State = get_node_or_null("HeavyAttack")

var combo_time: float = 0.0

func _update(_delta: float) -> void:
	if combo_time < combo_timeout_time:
		if light_attack_child_state:
			if owner.input_buffer.try_consume("gp_light_attack"):
				state_exiting.emit(str(owner.state_machine.get_path_to(light_attack_child_state)).replace("/", "-"))
				return
		
		if heavy_attack_child_state:
			if owner.input_buffer.try_consume("gp_heavy_attack"):
				state_exiting.emit(str(owner.state_machine.get_path_to(heavy_attack_child_state)).replace("/", "-"))
				return
	
	combo_time += _delta


func _enter_state() -> void:
	combo_time = 0.0
	owner.animation_player.animation_finished.connect(return_to_idle)
	super()


func _exit_state() -> void:
	if owner.animation_player.animation_finished.is_connected(return_to_idle):
		owner.animation_player.animation_finished.disconnect(return_to_idle)


func return_to_idle(_name: StringName) -> void:
	owner.animation_player.animation_finished.disconnect(return_to_idle)
	state_exiting.emit("Idle")

I think anyone in the future, looking for the same solution I was after, may infer what code I used for my state machine from this class. It is a node-based one, so shouldn’t be that hard. Consider that your homework.

Now, for the last issue I have, which I might just ignore and allow for a held autocombo instead, but I think might be a good lesson to learn: How do I queue a combo chain here? Just typing this is giving me ideas, but I’d like to hear the opinions of you better coders than me.

Otherwise, I’d like to thank everyone who chimed in, it has helped me immensely with this project :3

FYI, you don’t need to initialize empty collections in GDScript. It’s the same as:

var actions: Array[BufferedAction]

The ActionBuffer seems overcomplicated for what it appears to do, but it working is the most important part. It seems overcomplicated because I’m not sure why you would need to remove actions from the middle.

I would also recommend your push() signature be push(buffered_action: BufferedAction). Same with try_consume() and try_purge(). Reason being you said you were tying to minimize processor usage, which I assume means you want it performant as possible. Passing a StringName is optimal, but you MUST pass it as one. If you ever pass a String instead, you are bloating your memory and slowing down your comparison. So you have to be very careful using it. You are not passing StringNames, so you are losing the benefit of passing them.

#String
"GP_JUMP"
#StringName
&"GP_JUMP"

StringNames use a table lookup, which is much faster than a String comparison. However when you pass an Object, you instead pass a Reference to the object, which is a memory location, and comparing those is also very fast. Every Reference is 24 bytes in size. Period. A StringName is smaller at 8 bytes. A String is 24 bytes, plus 4 bytes per character in the String.

If you look at your function signature:

func push(kind: StringName, window: float, held: bool, holdable: bool)

That is a minimum of 8 bytes + 8 bytes + 20 bytes + 20 bytes = 56 bytes. And that’s if the StringName isn’t passed as a String, and the float isn’t passed as a Variant. Which then passing “GP_JUMP” becomes 68 bytes + 24 bytes + 20 bytes + 20 bytes = 132 bytes.

Or you could instead pass ActionBuffer for 24 bytes that will never grow, and is compared by memory address.

Additionally, your tick() function would be much more performant inside your ActionBuffer object. Let each object manage its own remaining time and countdown. No need to involve the ActionBuffer in that.

Also, your code in _unhandled_input() is doing all those String manipulations every frame. 60 times a second! That is a waste of processing power. Which is why all the examples hard code the actions and search only for the ones that matter.

So, changed here:

class_name Player extends CharacterBody2D

@export var InputBufferTypes: Dictionary[String, Dictionary] = {
	&"GP_JUMP": {&"input_buffer": 0.1, &"holdable": true},
	#&"GP_LEFT": {"input_buffer": 0.0, &"holdable": true},
	&"GP_DOWN": {&"input_buffer": 0.1, &"holdable": false},
	#&"GP_RIGHT": {"input_buffer": 0.0, &"holdable": true},
	&"GP_RUN": {&"input_buffer": 0.0, &"holdable": true},
	&"GP_LIGHT_ATTACK": {&"input_buffer": 0.15, &"holdable": false},
	&"GP_HEAVY_ATTACK": {&"input_buffer": 0.15, &"holdable": false},
}

func _unhandled_input(event: InputEvent) -> void:
	for input_type in InputBufferTypes.keys():
		if event.is_action_pressed(input_type.to_lower()):
			var action: BufferedAction = BufferedAction.new(input_type.to_lower(), InputBufferTypes[input_type].input_buffer, true, InputBufferTypes[input_type].holdable)
			input_buffer.push(action)
		elif event.is_action_released(input_type.to_lower()):
			var action: BufferedAction = BufferedAction.new(input_type.to_lower(), InputBufferTypes[input_type].input_buffer, false, InputBufferTypes[input_type].holdable)
			input_buffer.push(action)
			input_buffer.try_purge(input_type)
func push(action: BufferedAction) -> void:
	# dedupe: if it's already buffered, just refresh the timer
	for existing_action in actions:
		if existing_action.kind == action.kind:
			if action.held and action.holdable:
				existing_action.time_left = action.window
			existing_action.held = action.held
			return
	actions.append(action)

I also changed all input related strings to stringnames, I noticed they required a stringname too, so thanks for the help there! I also changed the state machine to use stringnames instead of strings, since it compares strings every time it’s changed states.
I really want this project to run at least at 30 FPS on every hardware capable of running its executable, so that helps a lot. (I am not giving up on Godot 4 for this, so that’s what I mean by “capable of running its executable”)

I’ll reconsider the removing things from the middle of the buffer. I’ll also consider making a second buffer that stores inputs and is able to get repeated inputs for the combos.

Although, on this:

It is on ActionBuffer..? Or do you mean BufferedAction? Because both are RefCounteds, so no _process() function, I need to call that from outside…

I did mean BufferedAction. I’d recommend you rename BufferedAction to just Action. It doesn’t know if it’s in a buffer or not and the naming is clearly confusing. Also, why are it and ActionBuffer extending RefCounted? (I missed that before.)

You are Future Proofing and WAY over-optimizing this game. The performance issues people encounter are always related to graphics and/or rampant procedural generation. The amount of optimization you are doing is just borrowing trouble from the future.

Godot is optimized for running games and written in C++. Don’t try to over-optimize in GDScript. You totally missed my point before, which is that passing Action objects is more performant in the long run than using StringNames. Because a simple typo will defeat the use of StringName. And if it you do need that level of optimization, you should be using GDExtension and C++ to optimize the pieces that are running too slowly.

If you are determined to optimize to that level at this point, you should also be doing Unit testing and be running a linter to check over your code for any accidental uses of String instead of StringName.

Sorry for the delay, spent the weekend without a PC and took me almost 2 days to configure it correctly on top of that.

I have no idea. @KerimCetinbas posted a snippet that extender RefCounted, and I just copied and pasted it. I have no idea what the benefits and drawbacks of extending RefCounted are, or what the other options would be.

I’m asking this as a complete noob when it comes to input and some basic concepts in Godot, because I usually follow tutorials and then try to understand how and why are they the way they are, but with something so complex as an actual fully fledged complex system for a very specific kind of game like this, it’s hard to infer what everything does, and why it’s done this way, from my perspective.

I do apologize if I’m demanding too much, or if it seems like I’m 100% clueless and not doing research, I just can’t find anything that actually hits the nail on the head when it comes to complex input systems, after some good days of searching.

You are trying to create a complex system without understanding the pieces. RefCounted is often used by programmers experienced with other languages who think they are optimizing when in fact they are missing huge benefits in speed and processing to save a few bytes of memory.

You don’t know enough yet to build something this complex. If you’re not even making the architecture decisions, I cannot give you good advice on how to make other choices. Other than to say, go find more tutorials.

I would also recommend you pay more attention to who you take advice from. @KerimCetinbas has been on this forum for two weeks and has no accepted answers on their profile. They do not know what they are doing with Godot yet, and are making the classic mistakes of an experienced programmer new to Godot. They are unintentionally giving you bad advice.

As an experienced programmer with limited Godot experience I always defer to @dragonforge-dev and a couple of others on this forum. (Usually I delete my own answer when something better pops up)

The safe advice here is when two people here disagree, check their profile pedigree (that rhymes so it must be true :wink:)

There aren’t any…
That’s why I came here…
That’s why the name of the topic is “Where to start”…

If you don’t want to give me the full answer because it’s a lot of effort, I’m right there with you, that’d be a lot of effort for a stranger to misread what you’re actually trying to teach, I agree that you shouldn’t go that far just for me. My problem is that I looked for a tutorial for at least a whole week, on multiple sites, on different platforms, and still came empty handed. Google gave me nothing, Youtube gave me nothing, other search engines gave me nothing.

If you know of a tutorial that goes in that direction, please refer me to that, because I’d love to learn the right way to do things. It’s just that looking for a specific problem (a hyper-specific and reliable input system) doesn’t really give you the building blocks to how to get there, it only returns something that someone else has done already, and no one has done something like that and made a tutorial about it yet.

I came to the forum because I found no other literature. Period. Maybe you can point me in the direction I need to go, so I can study it. Don’t make the input system for me, just teach me how to learn to do it, please.

I mean, I made you a whole example project, and then you decided to use the code that someone else posted. Which is fine. But then you’re asking me to fix someone else’s bad code which you don’t understand.

If you have questions about the project I created for you, go for it. I had fun working on it and would be happy to discuss with you how to make it fit your needs better.

Hello;
I want to address a few of the technical claims directly, because a couple of them are backwards and it’s easy to verify in-engine.


“RefCounted saves a few bytes of memory at the cost of speed/processing.”

This has it inverted. RefCounted is the lightest base for a plain data/logic object. A class_name script with no extends line already defaults to RefCounted:

class_name Foo          # extends RefCounted implicitly

and this is the same:

class_name Foo
extends RefCounted

The heavier option is Node, which carries scene-tree membership, transforms, notifications and per-frame processing none of which a small object holding kind and time_left needs. So writing extends RefCounted isn’t an optimization trick, it’s just being explicit about the default. There’s no memory-vs-speed tradeoff happening. (This implicit default is GDScript-specific; in C# you always name the base explicitly.)


“Passing Action objects is more performant than StringNames.”

This is backwards. StringName is interned every identical &“jump” resolves to the same internal entry, so a comparison is an identity check on that entry, not a character-by-character compare:

# O(1) id comparison, zero allocation on the hot path.
if _buffer.try_consume(&"jump"):
    _jump()

Passing objects around instead does the opposite of what you described it adds allocations. Every action you’d hand around is a heap alloc plus refcount bookkeeping, where the StringName path allocated nothing. In GDScript that’s allocation churn on input; So “objects are more performant in the long run” is the reverse of how it actually works StringName is already the allocation-free path.


“You’re over-optimizing / borrowing trouble from the future.”

An input buffer isn’t an optimization it’s a gameplay feature (jump buffering / coyote time). The per-action timer exists because the feature is “hold the action for a short window, consume it when conditions are met, expire it otherwise.” That’s the whole mechanic:

func tick(delta: float) -> void:
    for i in range(_actions.size() - 1, -1, -1):
        _actions[i].time_left -= delta
        if _actions[i].time_left <= 0.0:
            _actions.remove_at(i)

@dragonforge-dev — there’s a more basic issue: the example you posted isn’t an input buffer at all. Here’s the core of it:

if Input.is_action_just_pressed("light_attack"):
    if attack_number >= 3:
        return
    input_buffer.append(Attack.LIGHT)
if input_buffer[0] == Attack.LIGHT \
and input_buffer[1] == Attack.LIGHT \
and input_buffer[2] == Attack.LIGHT \
and input_buffer[3] == Attack.HEAVY:
    grow()

There’s no time window and no expiry anywhere; actions are appended on press and matched by fixed index. That’s an attack/combo queue, not an input buffer. An input buffer holds an action for a short window and lets gameplay consume it when conditions are met, then drops it when the window passes (coyote time / jump buffering) which is exactly what the thread title is asking about. The two aren’t interchangeable, and the timed-buffer approach you’re calling over-engineered is the part that actually implements the mechanic in question.


So the critique is aimed at the wrong target: this is a correct-sounding objection about a concept the example doesn’t actually implement. On the specific engine claims; the RefCounted default and StringName interning above those aren’t a matter of taste; they’re verifiable in-engine.


And here how it actually allocation works on godot

extends Node

const ITERATIONS := 1_000_000
const OBJ_ITERATIONS := 100_000

func _ready() -> void:
    _print_header()
    _bench_stringname_compare()
    _bench_object_alloc()
    _print_separator()
    _bench_range_vs_direct()
    _print_footer()


func _bench_stringname_compare() -> void:
    var a := &"jump"
    var b := &"jump"
    var before := Performance.get_monitor(Performance.OBJECT_COUNT)
    var matches := 0
    for i in ITERATIONS:
        if a == b:
            matches += 1
    var after := Performance.get_monitor(Performance.OBJECT_COUNT)
    _print_row("StringName compare", ITERATIONS, int(after - before), "objects")


func _bench_object_alloc() -> void:
    var kept: Array = []
    var before := Performance.get_monitor(Performance.OBJECT_COUNT)
    for i in OBJ_ITERATIONS:
        kept.append(BufferedAction.new(&"jump", 0.15))
    var after := Performance.get_monitor(Performance.OBJECT_COUNT)
    _print_row("BufferedAction.new()", OBJ_ITERATIONS, int(after - before), "objects")
    kept.clear()


func _bench_range_vs_direct() -> void:
    var before_r := Performance.get_monitor(Performance.MEMORY_STATIC)
    var arr := range(ITERATIONS)
    var after_r := Performance.get_monitor(Performance.MEMORY_STATIC)
    _print_row("range(n) held", ITERATIONS, int(after_r - before_r), "bytes")
    arr.clear()

    var before_d := Performance.get_monitor(Performance.MEMORY_STATIC)
    var acc := 0
    for i in ITERATIONS:
        acc += i
    var after_d := Performance.get_monitor(Performance.MEMORY_STATIC)
    _print_row("direct-int loop", ITERATIONS, int(after_d - before_d), "bytes")



func _print_header() -> void:
    print("")
    print("┌─────────────────────────┬─────────────┬──────────────────────┐")
    print("│ Operation               │       Count │ Allocated            │")
    print("├─────────────────────────┼─────────────┼──────────────────────┤")


func _print_separator() -> void:
    print("├─────────────────────────┼─────────────┼──────────────────────┤")


func _print_footer() -> void:
    print("└─────────────────────────┴─────────────┴──────────────────────┘")
    print("")


func _print_row(op: String, count: int, value: int, unit: String) -> void:
    var op_col := op.rpad(23)
    var count_col := _thousands(count).lpad(11)
    var val_col := (_thousands(value) + " " + unit).rpad(20)
    print("│ %s │ %s │ %s │" % [op_col, count_col, val_col])


# insert thousands separators: 1000000 -> "1,000,000"
func _thousands(n: int) -> String:
    var s := str(abs(n))
    var out := ""
    var c := 0
    for i in range(s.length() - 1, -1, -1):
        out = s[i] + out
        c += 1
        if c % 3 == 0 and i > 0:
            out = "," + out
    return ("-" if n < 0 else "") + out


class BufferedAction extends RefCounted:
    var kind: StringName
    var time_left: float
    func _init(p_kind: StringName, p_window: float) -> void:
        kind = p_kind
        time_left = p_window

It’s not inverted. I just believe you just did not understand what I was saying. Godot is optimized in C++. Nodes are optimized to execute _process() and _process_input() every frame. Your implementation assumes that you can squeeze more performance out of the engine in GDScript (or C#) than the original C++ implementation can.

I thought I had pointed out that an script not extending from anything extends RefCounted. But perhaps I just thought it this time. I’ve had this discussion a few times on here before.

The trade-off is that instead of operating on an object that has more functionality, you are operating on one that has less.

In this case, all I’m suggesting is that IMO, a better architecture for Godot, is to let the Action objects handle their own expiry - which requires them to be Nodes.

Again, either I mis-typed, or you misunderstood.

I was trying to explain to someone who is not a programmer how they better be very careful, and how their implementation of what you suggested would become less performant if they did not pass StringNames. GDScript allows you to pass Strings as StringNames without complaint, and as soon as you do that, you lose all the performance of using StringNames - in which case passing an object reference is faster. Which is exactly what OP was doing in the code they posted.

Aside from that, using StringNames in this context is overkill. It is a future-proofing optimization that in this case, makes the code more complicated than it needs to be for someone who does not really know what they’re doing when it comes to coding.

You know more about the definition of an input buffer than I do. I just went with what was requested in the post. I have never made a fighting game, so I was unaware of an architectural design pattern for input buffers. So I accept your criticism as valid.


Based on @BalaDeSilver saying that they want to understand how it works, I personally think that the architecture would benefit from focusing on easier to understand code that leverages the Godot way of doing things, rather than highly-optimized code that may be unnecessary for performance.

I agree, but at the same time, I’m trying my damn hardest to make the foundation of this project as solid as I possibly can. The project I’m working on that required me to post here about this is a personal pet project turned potential hailmary for the studio I’m working on.

It is still my pet project, but I intend it to be fully worked on by the rest of the team, when the time comes for it. My goal with it is that, whenever the rest of the team (an undetermined time away from now), it’s supposed to be as frictionless to be worked on as possible.

I know I’m making a lot of mistakes with it, I’m not that great of a coder (though I do think I’m a tidy one at that), I know this line of thought itself is a mistake, but I simply won’t let that stop me from trying my very best. Hence, I’m learning as much as I can, as fast as I can. If I fail with this project, I’ll fail knowing I did my best.

That all being said, why not just use Resource as a type, instead of RefCounted? Wouldn’t that be the happy middle child that has the most versatility? That’s how I understand the Resource vs RefCounted tradeoff lol

I wan’t to mention someone said about on the “Godot way” point, briefly: I think that phrase does a lot of quiet work in these discussions. Something gets called “the Godot way,” it gets repeated, and it becomes the convention, but that’s not the same as it actually being how the engine wants things done.

So instead I try to check a claim against what the engine’s own API actually expects. The thing that pushed me toward that: the same topic often shows up with directly opposite advice. In one place something is “the Godot way,” and somewhere else the same thing is “you’re doing Godot wrong” or “stop using Nodes for everything.” Which one is actually idiomatic ends up being pretty relative. What makes it hard to just defer to public references is that they tend to repeat each other, rarely explain why an approach is correct, and almost never weigh a few approaches side by side, sources that do are maybe one in a hundred. Genuinely professional references are hard to find in the open. So for my own work I default to the API as the reference point.
By that measure StringName and RefCounted lean toward idiomatic rather than away: Input.is_action_pressed() already takes a StringName, and RefCounted is the default base when you omit extends. Neither is an add-on.

@dragonforge-dev

On Node vs. plain object for expiry; I left a small benchmark in an earlier post; it runs on an empty scene, so it’s easy to drop in and see the numbers rather than take my word for it. Short version: a Node per action means Node.new() + add_child() + queue_free() per press, versus decrementing a float in one loop. The C++ _process path is fast, but you’d be paying node-management cost to save a near-zero arithmetic cost on a list that’s usually 1–2 items. Right tool, wrong job.

The part I’d agree with: if you don’t want to wire the tick manually, you can hand that responsibility to a Node. Make ActionBuffer extends Node, let it tick itself in _physics_process, drop it in as a child, and the consuming side never calls .new() or tick() by hand:

class_name ActionBuffer
extends Node

# same implementation

...

func _physics_process(delta: float) -> void:
    tick(delta)

then consume it like so

@onready var _action_buffer: ActionBuffer = $ActionBuffer

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed(&"gp_jump"):
        _action_buffer.push(&"gp_jump", 0.15)

func _physics_process(_delta: float) -> void:
    if is_on_floor() and _action_buffer.try_consume(&"gp_jump"):
        _jump()

One caveat worth flagging though; once the buffer ticks itself, ordering moves out of your hands. Godot processes children before parents, so the buffer’s expiry can run before the entity’s consume in the same frame; which can defeat the “consume before tick” guarantee on a tight window. With a manual tick(delta) call you control that order directly; as a self-ticking Node you’d lean on process_priority instead. Real convenience, with a real tradeoff; worth knowing which one you’re choosing.

One footnote though: agreeing that the collection can be a Node doesn’t change my position on the individual actions. I’m still against turning a churn of short-lived objects; the BufferedActions themselves; into Nodes when they’re never part of the scene. One long-lived Node managing them is fine; dozens of transient Nodes created and freed on the scene tree per input is the part I’d avoid.


One more thing on the “over-optimization” framing, because I see it differently. Avoiding an allocation when the simpler code already avoids it isn’t optimization to me; it’s just the right default. The thing I’m wary of isn’t a single allocation; it’s the habit of waving each one through as “not worth worrying about,” because those are exactly the ones that accumulate quietly and turn into a bottleneck you can’t easily point at later. Real optimization; profiling, then restructuring hot paths, is something you do later, once you can measure. But not creating garbage in the first place isn’t that; it’s just not creating it.


@BalaDeSilver

first off, that mindset (solid foundation now, frictionless for the team later) isn’t the mistake you’re worried about. Wanting a clean base others can build on is the right instinct for something that might grow into a team project. You don’t need to be a “great coder” for that to pay off. Being a tidy one covers most of it.

On Resource vs RefCounted: this is a really common way to picture it, but “Resource = more versatile middle child” isn’t quite the tradeoff. It’s less “more powerful” and more “built for a different job.”

RefCounted is the lightweight base for plain runtime data; objects created, used, and freed during play. That’s exactly what a buffered action is: it’s born on a keypress, lives a few frames, and disappears. Never saved, never edited in the inspector.

Resource adds a layer on top of RefCounted: it can be serialized to a .tres, edited in the inspector via @export, and shared/loaded across scenes. That layer is great but only when you use it. For a transient action, none of it applies.

There’s also a subtle trap: Resources default to shared instances. Load the same .tres in two places and you get the same object. For a per-action timer that’s the opposite of what you want, each action needs its own time_left, or they’d stomp on each other’s countdown. You can work around it (resource_local_to_scene, or always .new()), but then you’re fighting Resource’s nature to get plain-RefCounted behavior back.

So the clean split: the buffered action stays RefCounted (transient, hot-path), and Resource is the right tool one level up, for config:

class_name InputBufferConfig
extends Resource

@export var jump_window: float = 0.15
@export var dash_window: float = 0.10

That’s a Resource earning its keep: exported, inspector-tweakable, saveable as a .tres, so teammates can tune buffer windows per character without touching code. The runtime action it configures stays RefCounted. Config in Resources, hot-path data in RefCounted and that division is about as frictionless-for-a-team as it gets, which is exactly what you’re aiming for.


Personally I wouldn’t mix buffering and held actions in the same structure; They feel like two different questions that want two different answers.

A buffer is about the recent past: “was this pressed a moment ago, and is that still recent enough to act on?” It’s edge-triggered (the press), it expires, it’s consumed once.
A held action is about now: “is this down at this instant?” It’s a state, not an event; true while held, false when released. Nothing to expire, nothing to consume.

When both live in one BufferedAction (with window and held/holdable), the two meanings start sharing fields e.g. time_left doubling as both the buffer’s expiry and the “still held” flag and that’s where the release/try_purge path comes from. Keeping them apart keeps each side small:

# Buffer: only press events, each with its own expiry.
func _unhandled_input(event: InputEvent) -> void:
    for action in buffered_actions:              # StringName keys
        if event.is_action_pressed(action):
            _buffer.push(action, windows[action])

# Held: just queried when you need it, never buffered.
func _physics_process(_delta: float) -> void:
    if Input.is_action_pressed(&"gp_run"):
        # move at run speed
        pass

The same split covers charged attacks; that’s a third thing, a hold duration: a timer counting up while held, as opposed to the buffer’s timer counting down to expiry. Same idea, opposite direction, its own place:

# Held duration (charge): a timer that counts UP while the key is down.
# Note: this action isn't in the buffered set — it's a different mechanism.
var _charge := 0.0

func _physics_process(delta: float) -> void:
    if Input.is_action_pressed(&"gp_charge"):
        _charge += delta
    elif Input.is_action_just_released(&"gp_charge"):
        _release_attack(_charge)   # light vs charged based on _charge
        _charge = 0.0

And what is your opinion on a double buffer approach? I guess I technically already have a second buffer, in the form of a tree-style state machine. I thought the team would really appreciate a node-based state machine to implement the combo system itself, since the tree structure of the scene tree is inherently easy to understand. Then the animations‎ are called by the full node path as a single string (replacing the ‎"/" character by “-”, since you can’t put a slash in an animation name).

Am I really going in a correct(~ish) path by making the architecture this way? The combo moves are already states in the machine, so no need to make special resources for each move, I already added the specific data to the nodes before you commented that.

I guess I’m mostly insecure by which way to approach tying this system together, now. I have most of the info I need to put it together (I think), it’s more a matter of architecture, now.

Hello;

If you mean literally having more than one buffer, say a separate buffer for charge inputs alongside the attack buffer, that’s completely fine. Nothing says you can only have one; buffering different kinds of input separately is reasonable, and you can have as many as the design needs.

But if the “second buffer” you mean is the state machine, then that isn’t really a buffer at all. The buffer and the state machine do different jobs. The buffer holds a raw press for a short window, the state machine holds where you are in the combo. You feed the state machine with the buffer, and you consume it on the state machine side. So it’s not duplication; they’re pieces that feed into each other, not competing copies. That’s exactly how it should work.

Node-based state machine: since you’ve already decided on a node-based FSM, I won’t get into alternatives. It’s a common, readable pattern in Godot, and your reasoning holds: the scene tree really does make “which move follows which” easy to follow, which is a real win for a team.

Animations by node path: your current method derives the animation name from the node’s tree path (the / to -). It works, but the name depends on where the node sits in the tree. Move or rename a state and the generated name changes while the AnimationPlayer’s animation doesn’t, so they silently stop matching.

If it were me, I’d give the name directly instead of deriving it, either hardcoded on the state or as an exported field:

@export var anim: StringName
_animation_player.play(anim)

Since you already put move data on the nodes, it’s just one more field, and the binding no longer depends on the tree structure.

If you do want to keep deriving it from the path, a replace gets you there:

var anim := str(get_path()).replace("/", "-")

but I wouldn’t lean on it. It still ties the name to the tree structure, so the silent-break-on-rename problem stays; the explicit field just avoids the whole issue.

Oh, forgor to mention: I’m doing a path relative to the state machine itself, by doing owner.state_machine.get_path_to(node), so the only issue is the renaming problem, but I’d probably want to use the node name in some way anyways(?)

Like, I need a unique name per state anyways, so letting the automatic naming of “LightAttack” and “HeavyAttack” do the heavy lifting here seems like a good option. I have a single “Attack” state, which I extended twice in empty classes, specifically so one can simply add another attack in the chain by simply adding a node and forgetting about it.

The only problem I see with this approach is that it would be a pain to name each animation for each attack, but a compromise is going to have to exist somewhere, as I see it.

And by a second buffer, I mean something like in this video (I’m only taking about the first part of the video), where they suggest a back buffer, to store past inputs in the current combo chain, but I already have that with the FSM, so I guess I accidentally already covered that spot lol
(The video is good, but it didn’t help much on how to implement the system without needing to deabstract the contents, it’s the best video about the topic I found, but it didn’t help all that much)

if you want replace / with - something like;

str(owner.state_machine.get_path_to(node)).replace("/", "-") should work fine. And using the node name to carry the animation makes sense in your setup, since the states already need unique names anyway. The only cost is renaming the animation when you rename a state, which you already spotted.

Does casting from a String to a StringName by doing StringName(str(#that)) work like a static &"string"?