Discussing Architectural Patterns in Godot after 2 years of development

I’m sorry, what?
Where do we disagree?

Man this is actually sad =(.
Here I was thinking I was having a reasonable exchange of ideas.

It does only return the first level. If you look at the code, it’s a recursive script - it calls itself to hunt all the way down the tree. It’s an example of functional programming in action, hidden inside all my OOP code.

I made it for me and share it in case others want to use it. No worries if you want to make your own. I did the same. I saw Maack’s template in a Godot Wild Jam. Wanted to extend it and found the code to be way too clever to read. So I made my own.

Thanks for saying so.

And that’s fine. My goal is the best player experience. Which means speed of the UI. When the UI becomes so heavy it needs to be unloaded from gameplay, I’ll handle that. It has not been an issue for me yet.

If you (or anyone else) is asking those questions, my response is going to be, “Why are you worrying about that?” This is an example of future proofing, and it’s a bad habit. @normalized said it well:

If your answer is not, “It’s making my game lag.” Then this is a philosophical/religious discussion about software optimization. It’s not a practical question of you presenting your code, node structure, and the measurable problem you are having.

Because if you’re actually having the problem, I’m going to ask you to run the profiler, and your hardware system monitor. To tell me is it your GPU or CPU or RAM or HD Access that’s causing the lag? Is this on Windows, Mac, Android, iOS, Linux, or a specific device running one of those OSes? Is this an outlier or your target user group?

If you can’t answer all those questions, then it’s just a question for funsies.

Yes the answer is not “It’s making my game lag.”.
The simple answer is that this is a personal preference. I wouldn’t call it for “funsies” but it is something I like to take into consideration occasionally and did in the project I gave as an example.

Sure there is reason behind the madness but there is no need to go into details (it is not exactly on the topic of this post).

I do agree with @normalized comment as well as with this being a bad habit.

… “_connect_buttons(subnode)“, how did I miss that…

1 Like

I had a long post that I deleted but basically maintainable code is about it being easy to read, while that is different for people.

Most of them agree code that doesn’t repeat or duplicate itself is important. So is code that is shorter though that may not always be possible.

It’s value is simply in reducing the time taken to read it, having mental maps or shorthand for repeated code in functions and breaking up large blocks of code into small pieces or little summaries so u can quickly recall what it does or where to look when u need to change it

Higher level management always says, “Can u summarise what u said without the technical details?” that is basically it when u write maintainable code and break your functions up.

Clean code is for your benefit and mental wellbeing and truthfully also for those who read and maintain your code after u leave, that’s why the seniors or managers that say code that ships is good code are only half right. If I am being honest, I feel they say that cause they passed their shit to you and are trying to rationalise or excuse their behaviour. They don’t give a shit XD.

Maintaining clean code is sometimes a disadvantage in the real world.

That means when retrenchment comes, you are on the list rather than those whose projects are unmaintainable but critical to business functions.

It means people can easily reuse your code for their own purpose cause they know where to look and they say “X is dispensible but I am not” cause they got the company by the balls for excusing their bad behaviour.

But that doesn’t take away its benefits honestly.

P.S There is a term for what hooverskull did.

What he did was not refactoring. It was Refucktoring. That means u rewrite code and make it worse. I have seen people do these exercises for show and destroy code before on purpose and make it messier though it works.

Man, I lost the post where it talks about how to refucktor code so u are indispensible. It’s damn funny though I personally don’t do it

Could you take a look at Godot’s source code and evaluate how clean it is by your criteria? I can suggest starting at something basic like Object class:

I realise the world and alot of mainstream stuff is built on alot of messy code. I am not blind man.

Sometimes esoteric code is actually more performant too.

It is about knowing the trade offs. Don’t need to feel guilty for writing messy code @normalized I’ve written bad code like even now though I try to write clean code

P.S I don’t understand c++ so my evaluation would be unfair.

If you know Java you can read C++ without any problems and vice versa. Since code cleanliness principles are supposed to be language agnostic - you shouldn’t have much problems evaluating it.

There are concepts like pointers in C++ like what is done in there, and there’s also the headers they use that give them access to methods.

I wouldn’t really know all this if I don’t program in it all the time. Seems I touched a raw nerve. My boss was screaming when I talked about our project cause he wrote most of it

Clean code doesn’t deal with language specifics. I never heard any clean code advocate talking about pointers. Only about function and class sizes, names, arguments, stuff like that. So please if you will, I’d really be interested in hearing it.

Btw, I’m not a Godot contributor.

This is the part where I say it is out of my area of expertise.

Some parts are straight forward enough like constructors and destructors and freeing memory.

Some parts however I simply don’t know enough like

void:Connection where I assume it returns a connection with a void method? But then there is Connection:Connection.

Clean code unlike what you said is not language agnostic cause while the principles are in general the same, the language features and its understanding affect how you will write things.

Same way I wouldn’t do things in godot I would do in Java.

Unfortunately, have tried and failed cause I don’t fully understand C++ feature set. If u gave me gd script or a Java class I could read it and tell u if its bad.

On top of that, clean code has a requirement that it must still work. Without that understanding of the language and what the code does, I cannot make recommendations can I?

Amazing topic. Thanks for sharing.

My 2 years observations.

  • I like referencing things without any onready or get node or node path stuff. Just have @export_group(“Nodes”)
  • I don’t strictly observe the “no parents” rule for things that I know will remain unique or are just structural decomposition of a single larger thing.
  • I dislike deep inheritance, but use one layer of it with @abstract or without and there’s an exception if I need to follow some outside class structure or there’s an addon class that I extend by having “my game” layer inbetween the reusable bits.
  • Whenever possible I create pure observable data and even go “serialization first” with it. So basically my game saves/loads from its first week. Makes testing faster and makes me think in terms of cleaner data.
    • For in-scene non-systemic stuff I do serialize in place.
  • My “game” autoload is unaware of the UI as much as possible. It has signals and the UI is very aware of the game one and assumes its structure quite a bit.
  • I do take care to separate game specifics from generic code, but don’t fuss about it much. Simpler and specific code is easier to reason about.
  • I use insane amounts of tiny nodes and connect them up using signals. The signal marker makes the active objects apparent. Coordinator is the top level object and I strongly avoid “editable children” to keep it manageable.
  • The best component I built is a “visibility coordinator” which basically coordinates its children to determine if something should be visible. I can write those tiny nodes that all need to be true for something to be visible and there’s a priority layer for debug key which can make all the invisible things visible at the time.
  • Folder structure: data locality is the way how I handle most of it so I keep code and art together as much as possible. Entities is a mega-directory containing the object type folders and Stages is the one which keeps the parents to the player character entity. Autoloads has its own directory and Data is anything related to the data model or configurable/tweakable defaults.

Sorry, it’s terse as I’m pretty busy right now.

I’ve read through the topic a bit and I have to say there are some interesting ideas floating around in here.

But at the end of the day, I highly disagree with any notion that you should never, ever learn about design patterns or that you should just “ignore them and just make your game”, because even if you end up only using ONE of the many design patterns you learned, in only ONE system in your game, that might already make your job a lot easier later down the line. And learning these patterns can also help once you crossed the “I have no idea what I’m doing” threshold and you want to learn something more advanced that isn’t just “var means variable”.

Of course, you should not build your game on top of any single pattern, or patterns. That’s just silly. However, start making your game, and during development, if you notice “Huh, I’m doing something that reminds me of this pattern I heard about, maybe I should try to implement it here since what I’m doing seems really difficult to maintain”, then go for it! If it helps, you likely saved some headaches down the line or even a complete roadblock / burnout that comes from unmaintanable code.
However, if said pattern looks clean but actually requires you to write more and more code as the system grows bigger, just drop it. If it’s better to hard-code variables, do that instead of spending 50+ hours making a system that you’ll probably end up changing later down the line anyway.

Also what I learned super early on: Make the system you have in mind first in the most messy but quick way you can. Then see how you can make it cleaner. This is where design patterns CAN help, if needed, but if you made your system and it’s messy BUT FINISHED, and it WORKS, then you’re good! No need to complicate it further. Unless it’s a really important system you will interact with a lot, but, again, with experience you’ll figure out how to deal with that.

4 Likes

I think you’re saying that because you haven’t experienced enough languages. Because these things are about things like cognitive load. How long does it take one to read the code? Are there too many branches or loops? These things don’t change based on the language. The grammar changes, but the underlying organization does not.

For example:

Java

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

GDScript

extends Node


func _ready() -> void:
	print("Hello, World!")

Or:

Java

public class HelloWorld { public static void main(String[] args) { System.out.println("Hello, World!"); } }

GDScript

extends Node
func _ready(): print("Hello, World!")

True, but Java has no concept of pointers

GDScript has no concept of interfaces

And Python has list comprehension which well, Java has come up with a certain version of.

It’s kinda like if u can’t import multiple packages from multiple files, or there are some side effects, then lumping everything u need it one file makes sense. So there are different things that are considered good practice in different languages also.

Man, I had this discussion about with normalized offline also. And he wants me to pony out good production code. U expect me to leak my company code and get sued or something? The most I can give are the differences between good and bad implementations of a health meter from my own code, but that’s a discussion for another time.

Interfaces, for the most part, are just abstract classes, which godot does support, but I get why it’s not the same, single inheritance and all that (Microsoft Java has the same).

But in my opinion, it’s quite comfy to simply use the has_method function, or as others probably talked about before, composition over inheritance.

1 Like

Two years in, here’s some actual non-corporate code — composition as it really plays out in Godot

Reading this thread, the recurring tension is: “patterns vs just ship it”, “is clean code language-agnostic”, and the very honest “you expect me to leak my company code?”. Fair. So here’s the other thing: a 2-year-old game project where I can show the code, because it’s mine. Not a toy. Let me walk through the one pattern that actually paid for itself, and why it pulled editor plugins along with it.

1. In Godot, composition is not a pattern you “apply” — it’s the grain of the wood

The scene tree is a composition tree. A node with child nodes is already “has-a”. The mistake I made early (and that the “just inherit” camp keeps making) was modelling Enemy extends Entity, FlyingEnemy extends Enemy, FlyingShootingEnemy extends… — the classic diamond that this thread has already buried a few times.

What I do now: a feature is a Node subclass that does exactly one thing, lives in components/<concern>/, and is wired by @export NodePath + signals. No component reaches up with get_parent() or sideways with $"../../Foo". The scene author connects them in the editor.

Concrete: my Coin.tscn is just a tree —

Coin (Node2D, group "coin")
├── AnimatedSprite2D
├── Area2D / CollisionShape2D
├── AnimationPlayer
├── RayCast2D
├── Collectible            # component: detect player, play pickup, free
└── NetworkCollectibleSync  # component: replicate the pickup to peers

Collectible knows nothing about coins, scores, or doors. It detects a body in its Area2D, emits collected(body), plays an animation, frees the parent. That’s the whole job:

signal collected(body: Node2D)

func _on_body_entered(body: Node2D) -> void:
    if not body.is_in_group(player_group):
        return
    _collect(body)   # emit collected, play pickup anim, queue_free

The same Collectible is dropped, unmodified, on a bottle, a key, a reward pickup. Zero inheritance. The “type” of the pickup is the scene it lives in, not a subclass.

2. The example that actually earned its keep: the decoupled actuator

Here is where composition stops being aesthetic and starts saving real time. I have a door. It should open when you collect every coin. Six months later: also a door that opens when a story flag is set. The inheritance reflex is CoinDoor / FlagDoor. Don’t.

The door is dumb on purpose — it’s a two-state machine that knows how to open and nothing about why:

class_name StateDoor
extends Node

func open() -> void:
    if _state == State.OPEN:
        return
    _state = State.OPEN
    # particles, sound, animation, emit door_opened

Then two interchangeable actuators, each a standalone component, each pointing at the door instance:

class_name CoinObjective extends Node
# gathers every node in group "coin", connects each Collectible.collected,
# counts down; on the last one: all_collected.emit(); _door.open()

class_name FlagDoorOpener extends Node
# watches current_game_flags; the first time `flag` is set: _door.open()

Swapping the win condition for a level is now: delete one component node, add the other. The door file is never touched. Collectible is never touched. There is no if objective_type == COINS branch anywhere — that branch is the choice of which component you parented. That’s the payoff the “patterns are overhead” people miss: the cost shows up later, as the absence of a refactor you’d otherwise be forced into.

3. Composition without contracts rots — so make the seams hard-fail

This is the part that’s easy to skip and expensive to skip. A loosely-coupled system is also a system where a missing wire fails silently three scenes later. So every “find my collaborator” seam asserts both directions:

static func find_in(root: Node) -> StateDoor:
    var found: StateDoor = null
    for child in root.get_children():
        if child is StateDoor:
            assert(found == null, "Multiple StateDoor under '%s'" % root.name)
            found = child
    assert(found != null, "No StateDoor under '%s'" % root.name)
    return found

Zero found → hard error. Two found → hard error (ambiguous wiring is a bug, never “take the first one”). And CoinObjective asserts that the level actually contains ≥1 coin — an empty objective must never read as “already won”. Composition buys you flexibility; cheap asserts at the seams are what stop that flexibility from becoming “why is the door open on level 4”. This, to me, settles part of the “is clean code language-agnostic” argument: the grammar is GDScript-specific (assert, signals, NodePath), but “make invalid wiring impossible to ship quietly” is the same instinct in any language.

4. Why this drags editor plugins in behind it — and why that’s the real reason plugins matter

Once behaviour is composed instead of inherited, the interesting part of your game stops being code and becomes data: which components, wired how, with what parameters. Two of my systems are pure data — an NPC dialogue graph (DialogData resources linked by choices) and an NPC lifecycle (a non-linear state machine of LifecycleState resources, “baby → adult → old”, a graph, not a line).

Editing that as raw resource arrays in the Inspector is miserable and error-prone. So each one has a project-local plugin: an EditorPlugin that registers a GraphEdit-based dock and an EditorInspectorPlugin, and lights up only for the right object:

func _handles(object: Object) -> bool:
    return object is NpcLifecycle   # the dock appears only for these

The point of plugins is not “tooling for tooling’s sake”. It’s the natural endgame of composition:

  • Composition turns logic into wiring; wiring is data; data wants a visual editor.
  • The plugin is itself a clean composition boundary — @tool, EditorPlugin + Dock + InspectorPlugin, all behind _enter_tree/_exit_tree. It can’t leak into the game runtime.
  • It ships inside the repo, versioned with the content it edits. No external tool, no format drift, no “designer needs a separate app”.
  • It moves authoring to whoever owns the content. A dialogue graph editor means the writer never opens a .gd file. That’s the actual ROI of architecture: not elegance, but who is allowed to change what without fear.

I want to be precise about how the two real plugins came to exist, because it’s relevant to the “do I need to plan this up front” debate: I did not write either of them in one go. The dialogue graph editor and the lifecycle state-machine editor each took roughly twenty iterations — paste, run, hit the wall, reshape, run again. They were not designed; they were converged on. Each iteration was small and the architecture only held up because composition kept the blast radius of every wrong guess tiny: a bad iteration broke one component or one dock, never the game.

The honest part: I built those twenty iterations with an AI assistant in the loop, and I’m not going to pretend otherwise. Two things are true at once, and they don’t cancel out:

  • Without AI I would never have gotten this far. A GraphEdit-based custom inspector plugin, a non-linear state machine over Resources, the decoupled-actuator refactor out of a god-object — that is a lot of surface area for a solo hobby dev. The assistant compressed weeks of “read the docs, guess, fail” into days of “try, see it fail concretely, adjust”.
  • But without AI I would also have quit long ago. This is the part people skip. Learning composition properly gets uglier before it gets better. The first time you split a working monolith into ten components wired by NodePaths and signals, it looks more complicated, not less, and every fibre of you wants to go back to the one big if. It is purely a longer learning curve — the complexity is front-loaded. The reward (swap a win condition by deleting one node; ship a dialogue editor so the writer never touches code) only shows up on the far side of that curve, and most solo devs burn out before reaching it. The AI didn’t make me a better architect; it kept me on the curve long enough to come out the other side, where you genuinely can do far more with the same effort.

So I’d reframe this thread’s “patterns vs just ship it” a little: it’s not that patterns are overhead or that they’re salvation. It’s that composition has a real, demoralising learning hump, and the thing that gets you over the hump — a patient pair, AI or human — matters as much as the pattern itself.

(There’s a third, boring-but-useful plugin: an EditorExportPlugin that rewrites a build_info.gd with the git short SHA + timestamp at _export_begin, so a shipped build can say which build it is. Twenty lines. Plugins don’t have to be grand to earn their place.)

5. Tying back to the thread

I’m with the “make it work first” crowd in spirit, but with a caveat the thread keeps circling: none of the above was designed up front. The coin door was an if in a god-object GameManager for over a year. It got extracted into a component the second a second trigger appeared and the if wanted to become two. The pattern wasn’t chosen from a catalogue — it was the cheapest way out of a concrete pain. That’s the honest version of “learn patterns”: you don’t apply them, you recognise the shape of the pain and remember that someone already named the way out.

And on the “show real code” point — this is all in a public-ish hobby project, so unlike production code I can just paste it. If it’s useful I’m happy to drop more of the component/actuator pieces in follow-ups. Real, ordinary, two-years-of-mistakes code beats another HelloWorld formatting argument.

6. Where I’m saying this from

For context on the bias I’m arguing against — my own. I’ve been writing code for over 30 years, across just about every language you’d care to name. And for almost all of that time I was a devout object-oriented guy: inheritance was the hammer, everything was a nail, a deep class hierarchy felt like good design. I wasn’t dabbling in OOP, I was an evangelist for it.

Learning to actually work with composition — not read about it, work with it — took a real mental rewiring. I’ll be honest: it demands more advanced intellectual gymnastics than plain inheritance. Inheritance lets you think top-down, in one tree, in one place; composition forces you to hold the wiring, the contracts, and the seams in your head at once, and to trust small parts you can’t see all of at the same time. That is genuinely harder up front, and after 30 years of the other reflex it was harder still to unlearn.

But once it clicks, it isn’t close. You are dramatically more flexible, and you keep running into fewer hard limits — the “I can’t add this without rewriting that” walls that deep hierarchies hit constantly just stop showing up. If someone with three decades of inheritance muscle memory can make that switch and not want to go back, the learning hump is real but it is absolutely worth climbing.

And here’s the point that actually matters: someone who has spent years being paid to tear into other people’s professional code and architecture before it shipped*, and who was a hardline OOP purist through all of it, still had to be dragged kicking up the composition curve. If it were obvious or easy, it would not have taken me, of all people, this long to convert. It wasn’t, I did, and I’m not going back.

7. What that flexibility actually bought — multiplayer and a conversation system that has no ceiling

The concrete proof is two things I genuinely did not expect to get cheaply.

First, multiplayer. Because pickup, scoring, and replication were already three separate components on the coin (Collectible, the objective, NetworkCollectibleSync), adding networked play was mostly adding a component next to the others, not surgery on a hierarchy. The “sync” concern slots in beside the “behaviour” concern instead of being threaded through it. With a deep NetworkedCoin extends Coin tree that same change is a rewrite; with composition it was an addition.

Second — and this is the part I’m most happy about — a conversation system that scales from “ambient” to “fully interactive” with no architectural break. Same composed parts, dialled up:

  • NPCs simply talking to each other while the player does nothing — pure ambience, the player isn’t even a participant.
  • The player walking into the middle of that conversation and being able to join it, mid-thread.
  • An NPC that asks the player to do something and hands over real items as a reward.
  • An NPC performing actions on other NPCs — which is how a tutorial can be delivered entirely through characters talking, the game teaching you by letting you overhear and then step into a conversation, no UI pop-ups.

None of those are different systems with if branches between them. They’re the same conversation/dialogue/lifecycle components, parameterised and re-wired. That’s the endgame of the whole argument: at this point I am genuinely not writing feature code any more. The system the iterations converged on is evolutive enough that what’s left is design and architecture questions — what should happen, not how to make the engine do it. For a solo dev two years in, “I’ve run out of plumbing to write” is the outcome I was actually chasing, and composition is the only reason it happened.


* Not the point of this post, hence the footnote: the “paid to tear into other people’s code” line is literal — I’m Olivier Pons, technical reviewer credited on 17 advanced technical books for Packt (Python, Django, the web stack). If you’d rather verify than take my word for it, search "Olivier Pons" technical reviewer Packt; I’m deliberately not linking my own site so you land on third-party results instead of my framing of them.

4 Likes

Here is a sample of my game when I’m developing stuff.

I’m not following the point you’re trying to make here.

GDScript has no interface keyword. So I guess you could say it has no concept of interfaces. But it’s built on the concept of nodes. A perfect example of multiple interfaces is my Camera2D Plugin.

Here’s the code for my interface, which is an @abstract class. It cannot be instantiated on its own.

CameraComponent2D
@tool
@icon("res://addons/dragonforge_camera_2d/assets/textures/icons/component.svg")
@abstract class_name Camera2DComponent extends Node
## Abstract base class for components added to a [Camera2D]. Objects inheriting
## from this class can only be attached to a Camera2D node and will issue
## an editor warning if not.

## The Camera2D object to which this component is attached and operates on.
var camera: Camera2D


# Runs whenver the node is parented or reparented.
func _enter_tree() -> void:
	camera = null
	var parent = get_parent()
	if parent is Camera2D:
		camera = parent
	update_configuration_warnings()


# Overridden built-in function of the [Node] class.
func _get_configuration_warnings() -> PackedStringArray:
	var warnings: PackedStringArray = []
	if not camera is Camera2D:
		warnings.append("Camera2DComponent only serves to provide modifications to Camera2D derived nodes. Please only use it as a child of a Camera2D to modify it.")
	return warnings

I then have multiple implementations of that class:

They all get attached to a Camera2D and modify it’s functionality together - thus creating multiple inheritance functionality. In fact, I implemented them that way to solve the multiple inheritance problem I was having (that Godot doesn’t have it). And I realized that using compositional objects with Nodes, it does in fact support multiple inheritance.

But whether a language has a feature or not has nothing to do with the concept of clean, readable code. Again, it’s all just grammar. And I think that’s what you’re getting hung up on. You’re focusing on the differences between three languages, and I’m saying when you get to a dozen languages you’ve used each for a few years each you might see where @normalized and I are coming from, and focus on the similarities instead of the differences.

What? No. There are different grammar things. Having a huge file is not a good practice in any language. If it’s necessary then that is a limitation of the language.

1 Like

@suredesm That was a brilliant and informative post. You gave me a LOT to think about.

Networking

I am really interested in the networking component you added. I’d really like to see the code for NetworkCollectibleSync in particular. I’m currently working on a game and we had to pull the networking code out once already and I’ve been trying to figure out how to make it more modular.

GraphEdit

I’m also really interested in seeing your GraphEdit code, especially your dialog stuff. I think I’ve gotten to the point with enemy AI and state machines where I’d really like to design them with a GraphEdit node. Because in the last two weeks I’ve worked on a 2D and 3D game, and I keep going over the same basic things in my code to hook everything up.

So I’m interested in your dialog system because I think it addresses a number of pain points I’ve been having with developing cutscenes and tutorials. But also because I want to see how I can adapt it to more systems.

I’ve also been an OOP purist for a long time, and using Godot has really opened my eyes to composition. Being on this forum has also helped me. Someone asked six months ago how to make a health component, and I answered by making one and walking them through it: Am I doing Components/composition right? - #3 by dragonforge-dev At the time, I was still against the idea of a health component. Last week, after using the health component in a couple games, I started work on a Health plugin that I could re-use and also tie to a bunch of different health bars.

I wanted to decouple my player UI from the player object, but also be able to reuse it for simple health bars hovering over enemies, etc.

Assert

I was using asserts for unit tests in GDScript a few years ago, but I’ve found that over time I don’t need unit tests if I throw errors in the correct places. I really liked what you said about finding the first one isn’t good enough, and how you used asserts in the find_in() function.

Collected

I had a question about this code:

signal collected(body: Node2D)

func _on_body_entered(body: Node2D) -> void:
    if not body.is_in_group(player_group):
        return
    _collect(body)   # emit collected, play pickup anim, queue_free

In the same place I would write:

func _on_body_entered(player: Player) -> void:
    player.collect()   # execute collected function on player, play pickup anim, queue_free

And I would rely on the fact that something is not a Player, it’ll throw an error and halt the game. Thus ensuring that a collectible object is only looking for things on the Player phsyics layer.

I’m curious how you got to your code. I personally avoid groups and @exported attached nodes for the same reason: I don’t want my game to fail because I forgot to make a connection that doesn’t throw a clear error.

Hi @dragonforge-dev,

Happy to share the NetworkCollectibleSync — it’s small and pretty self-contained. For the GraphEdit dialog system I’ll pass for now; it’s still tangled with project-specific assumptions and there are real bugs I haven’t ironed out yet — edits made in the inspector don’t propagate back to the graph editor, and vice versa. Until that two-way sync is solid I’d rather not publish it.

There’s also another thing that genuinely bugs me and that I’m not willing to ship without a fix: as far as I can tell, there’s no proper way to translate a plugin in Godot. I’m still picking my jaw up off the floor months after discovering that. Until I find or build a workable i18n story for the editor-side strings, this stays internal.

The design splits responsibilities across two sibling components on the same parent:

  • Collectible — local pickup logic, animation, queue_free. Has no idea the sync exists.
  • NetworkCollectibleSync — server broadcasts to remote peers, who replay the visual destruction only.

The contract is one method on Collectible:

## Plays the pickup animation and destroys the entity without
## game-logic side effects. Used by NetworkCollectibleSync on
## remote peers so they see and hear the pickup without touching
## the score or emitting `collected`.
func destroy_remote() -> void:
    if area:
        var shape: CollisionShape2D = area.get_node_or_null("CollisionShape2D") as CollisionShape2D
        if shape:
            shape.set_deferred("disabled", true)
    if animation_player and animation_player.has_animation(pickup_animation):
        animation_player.play(pickup_animation)
        await animation_player.animation_finished
    _parent.queue_free()

And the sync itself:

## Synchronizes collectible pickups across all peers.
##
## Finds a sibling Collectible on the parent. When collected, the
## server broadcasts to remote peers which call
## Collectible.destroy_remote() for animation and sound. The local
## peer's Collectible handles its own lifecycle. Does nothing if
## the network is inactive.
class_name NetworkCollectibleSync
extends Node

var _collectible: Node = null

func _ready() -> void:
    if not network.is_active():
        return
    _collectible = _find_collectible()
    if not _collectible:
        return
    if _collectible.has_signal("collected"):
        _collectible.collected.connect(_on_collected)

func _find_collectible() -> Node:
    for child: Node in get_parent().get_children():
        if child.get_script() and child.has_signal("collected"):
            return child
    if get_parent().has_signal("collected"):
        return get_parent()
    push_warning("NetworkCollectibleSync: no Collectible sibling found")
    return null

@rpc("authority", "reliable")
func _rpc_destroy() -> void:
    if _collectible and _collectible.has_method("destroy_remote"):
        _collectible.destroy_remote()
    else:
        get_parent().queue_free()

func _on_collected(_body: Node2D) -> void:
    if network.is_server():
        _rpc_destroy.rpc()

Three things worth noting:

  1. Component-as-sibling, not inheritance. Drop both on the same parent and they wire themselves up. The Collectible emits collected exactly like it would in singleplayer — it has no networking awareness.

  2. Server is the only authority. Only the server’s _on_collected fires the RPC. Without that guard you’d get N peers all broadcasting the same destruction.

  3. destroy_remote()_collect(). They look similar but the difference is deliberate: _collect() emits collected (which feeds score / inventory / quest triggers on the picking peer); destroy_remote() skips all that and only plays the visuals, because the side effects already happened on the original peer.

About your is_in_group("player") vs func _on_body_entered(player: Player) question — in my game the controlled body can swap at runtime (different characters), so a static Player type isn’t a stable contract. The trade-off you flag is real though: I lose the “crash on miswire” safety. I lean on an assert at player spawn time instead, which catches the config error early rather than at first overlap.

1 Like