Discussing Architectural Patterns in Godot after 2 years of development

@Dad3353

The honest answer is that I don’t know of a single canonical book on “composition in Godot”. Most material is either generic OOP-vs-composition philosophy or Godot tutorials that demonstrate the pattern without ever naming it. Two pieces I’d actually point at by name:

  • Robert Nystrom, Game Programming Patterns. The entire book sits on the author’s website for free. Read just the Component, Observer, and State chapters and ignore the rest until later. It’s deliberately language-agnostic, and it’s the closest thing to a textbook for what I described above — none of it is Godot-specific, but every chapter clicks the moment you reopen the engine.
  • The engine’s own “Best Practices” page in the official Godot documentation. Short, opinionated, and the only piece I treat as authoritative on this topic — direct guidance from the people who built the engine, on when to use the scene tree, when to inherit, and why to prefer composition. Worth re-reading every six months.

A piece of advice truer than any book: build a tiny throwaway project, ten components or so, and deliberately over-decompose it. Force yourself to express each behaviour as a Node child wired by @export plus signals. About half of what you build will feel like overkill. The other half will quietly save you on iteration five. That’s the curve I described. Reading about it will not get you over it — building one ugly little game while too-aggressively splitting it will.

And, for what it’s worth: half a century of programming is exactly the wrong reason to feel late. The only thing you’d unlearn is the reflex, and you already know that one from doing factory OOP work over the decades. The grammar is new, the instinct — “model this domain correctly before writing it” — is the same one you’ve been training for fifty years.

(Time machine: bring back two lottery tickets. One for me.)


@normalized

It’s not that composition is hard per se. You (as many of us) have just been too deeply indoctrinated to be able to think any other way.

Hard to disagree. “Indoctrinated” is more honest than “trained”. What made it sticky wasn’t repetition — it was that every textbook I grew up on framed OOP as the correct answer to “how do you model a domain”, not as one option among several. The first time someone showed me a flat composition diagram I read it as “incomplete UML, the author forgot the hierarchy”. The hump isn’t conceptual difficulty, it’s deprogramming a reflex.


@dragonforge-dev

Happy to go through these at the architecture level — the actual GraphEdit dialog editor stays on the shelf until the inspector ⇄ graph two-way sync is solid, but the runtime side I can talk about openly.

Quick honest disclosure first, because the original post hinted at this but the questions in this thread deserve the fuller version.

I’m French. The English you’re reading is mine in structure and intent, but the wording has been smoothed by an AI assistant — at this register it’s noticeably tighter than what I’d write solo, and I’d rather acknowledge that than fake fluency.

On the project itself: at least two thirds of what’s described above was built by me, alone, in a first phase that ran for many months. Then I walked away from it. For over a year. Stuck on the same recurring shape of problem, demoralised by it, the project paused in a folder. I would never, on my own, have decided “I’m going to reopen that project and push it through to the end”. The AI assistant is the only reason I came back to it — it pulled me up the curve one concrete iteration at a time, where on my own I’d have given the same iteration up after the second failure. The architectural decisions are mine, they’re the conclusions I converged on across twenty-some attempts that visibly failed before they worked — but without the assistant, the project would still be sitting in that folder. That’s the honest version and it belongs at the top, before anything technical.

Now the questions, one by one.

How is the conversation engine triggered? Area2D, or built into the NPC?

Both, intentionally separated. Every participant — NPC and player — carries a ConversationProximitySensor, which is just a thin Area2D + CollisionShape2D child that detects other participants. The “I want to talk” decision lives in a sibling node (ConversationBehaviorNpc or ConversationBehaviorPlayer) that listens to the sensor and calls Conversation.try_start(initiator, target, dialog_id, parent). The policy“is this participant allowed to start a conversation with that one, with what dialog?” — lives on the behaviour node plus a ConversationInteractionRules resource attached via a ConversationProfile.

A talkable NPC scene looks roughly like this:

Frog (Node2D, group "frog")
├── AnimatedSprite2D
├── DialogController              # comes with the entity_npc base
├── RewardGiver                   # comes with the entity_npc base
├── ParticipantTags               # tags = [frog]
├── InventoryComponent            # starting_items = […]
├── ConversationProximitySensor   # Area2D + CollisionShape2D, no exported props
├── ConversationProfile           # rules = rules_player_can_start.tres
└── ConversationBehaviorNpc       # sensor = ../ConversationProximitySensor
                                  # default_dialog_id = "frog_intro"

And the player carries the exact same shape, just with ConversationBehaviorPlayer instead — fully symmetric:

Player (CharacterBody2D, group "player")
├── …movement, animation…
├── ParticipantTags               # tags = [player]
├── ConversationProximitySensor
├── ConversationProfile
└── ConversationBehaviorPlayer

That’s the post’s pattern again: the Area2D doesn’t know what a conversation is, the behaviour doesn’t know about collision shapes, the rules resource doesn’t know about either. Three components, one wiring diagram visible in the scene.

How do players interrupt? Interruption points, or player-initiated?

Both, through the same door. Because the player has the symmetric setup, two NPCs talking can be joined by the player simply walking into the union of their sensor coverage — the player’s ConversationBehaviorPlayer requests to join the session, gated by the same rules resource. There are no explicit “interruption points” baked into the dialog graph; joining happens at session level, and the dialog the player drops into just gets a new participant. When the player walks back out of the combined coverage, the session auto-closes for everyone.

Is this also a quest system?

Not really — but it doubles as one cheaply. Every DialogData resource has flag_required / flag_forbidden fields checked by DialogController.is_excluded(), and choices can carry flag_changes: Array[SetFlagReward] applied before the dialog graph advances. So “quest state” is just the global flag dictionary (current_game_flags), and “quest progress” is dialog graph paths gated by those flags. No Quest class anywhere. A real RPG with branching journals would probably want one; for tutorial-grade flow this is enough.

How does that work as a tutorial system?

Same machinery. Level 2 teaches “open your inventory” entirely through one NPC — a sarcastic frog. The flow:

  1. Approach → conversation auto-starts (proximity). Intro dialog plays, no reward yet — the frog tells the player to open their bag.
  2. Player walks away → session ends, no drama.
  3. Player returns before opening the inventory → flag tuto_opened_inventory_once is unset → router picks one of three “nag” variants at random.
  4. Player opens the inventory anywhere in the level → inventory_ui.gd sets tuto_opened_inventory_once.
  5. Player returns after opening it → router picks the “bravo” dialog → RewardGiver does an ItemTransfer.move of an ItemStack from frog to player, and sets frog_tuto_done.
  6. frog_tuto_done triggers a FlagDoorOpener component on the level → exit door opens.

No tutorial-specific code. No “Press TAB to open inventory” popup. No tutorial manager class. It’s just dialog graph + flags + the same components I already had — the same FlagDoorOpener used elsewhere, the same RewardGiver used by every gifting NPC. The pedagogical content is data, not code.

How are NPCs constructed? State machine, or command pattern?

The scene tree above is the construction. No state machine class anywhere in the conversation system. The closest thing to “state” is (currently active DialogData) × (global flags dict). Dialog graphs branch via next_dialog_ids plus flag gates, and that graph IS the state machine, expressed as Resources rather than code. A DialogData is just data:

class_name DialogData extends Resource

@export var dialog_id: StringName
@export_multiline var text: String
@export var flag_required: StringName    # "" means no gate
@export var flag_forbidden: StringName
@export var next_dialog_ids: Array[StringName]
@export var choices: Array[DialogChoice]

The advantage is the obvious one: a writer who has never opened a .gd file can change the flow. They open the resource, type the text, set the gates, drag-link the next dialogs.

For the separate lifecycle system (the “baby → adult → old” graph I mentioned), it actually is a state machine — non-linear — but again expressed as LifecycleState resources linked by transitions, edited in a GraphEdit dock. Same idea: the data is the machine, the engine just walks it.

No command pattern. Dialog choices carry their rewards declaratively as resources; the controller applies them. Could be reframed as commands if I ever needed undo / replay; I don’t.

animation_player wiring

Yes, @export on the component:

class_name Collectible extends Node

@export var animation_player: AnimationPlayer
@export var area: Area2D
@export var pickup_animation: StringName = &"pickup"

func _ready() -> void:
    assert(animation_player != null, "Collectible: animation_player slot is empty")
    assert(area != null, "Collectible: area slot is empty")
    area.body_entered.connect(_on_body_entered)

You set the slots in the editor by drag-dropping the right node onto each @export field. Same model for every cross-node reference inside a scene — no $Path/To/Node, no get_node, no find_child, no group lookup. If the component is genuinely required, the assert in _ready makes a missing slot fail loudly at scene load; if it’s optional, it stays null and the caller guards on it.

The principle matters more than the syntax. This way the scene is the wiring diagram: open the Inspector, you see exactly which AnimationPlayer drives the pickup. Move the AnimationPlayer node anywhere in the scene, the reference stays valid. Delete it, the scene loads with a visible warning. Try the same with find_child("AnimationPlayer") and the link is invisible until it breaks at runtime.

Multiplayer — how does the server know who collected?

Server-authoritative on physics. The server runs the same _on_body_entered that singleplayer does — clients don’t tell the server “I picked this up”, the server sees the pickup happen in its own simulation and is the only side that emits collected and fires the RPC to peers. Client inputs are replicated to the server (input replication), the server moves the body, the body’s position replicates back via MultiplayerSynchronizer. So when a body enters the coin’s Area2D, that happens on the server first; the local-only signal on a client is just a visual echo.

That’s exactly why destroy_remote exists. Peers need a way to play the pickup animation and free the entity without re-running the game-logic side effects, because those already happened on the server. It’s deliberately asymmetric: _collect for the server (logic + visuals), destroy_remote for peers (visuals only). One method per role.

Your alternative — clients send RPC commands (move, jump, pickup-attempt) to the server, the server runs deterministic physics, the server broadcasts world state — is the lockstep / command-pattern model, and it’s strictly better for anti-cheat and replays. I didn’t go there because the game isn’t competitive. If I were writing a paid PvP game I’d absolutely lean that way. Your instinct to refactor toward it is a good one.

is_in_group("player") vs typed Player parameter

You’re right on both counts: the typed version is faster (the engine’s bitwise layer / mask check is in C++), and it’s safer (mis-wires crash at scene load, not at first overlap). I lose that, and I know it. Two reasons I still went with the group:

  1. In my game the controlled body can change at runtime — different characters, swapped pawns. There is no single Player subclass that’s always the right one to test against; what I actually mean is “currently player-controlled”. A group flips on and off cleanly; a class doesn’t.
  2. The Area2D’s collision layers / masks are doing the bitwise broad filter already. The group check is the secondary semantic check after physics has narrowed the candidates to a handful per frame. The performance gap at that point is real but tiny.

That said: your Character extends CharacterBody3D / Player extends Character pattern, even with empty bodies, is genuinely useful and not at all incompatible with composition. Type-as-marker is one of the cheap wins of static typing. I do the same on the resource side — SetFlagReward, ItemStack and friends extend a marker DialogReward with no behaviour. Empty types are fine. The thing I avoid isn’t typing, it’s behaviour inheritance.

League / paid combat / motivation

Nodded along reading that. The reason I’ll never write another competitive combat loop comes in two parts. The structural one first: the design loop is corrupted at the source. Every system you build is one more knob to balance against opponents who are paid full-time to find its limit. Cooperative and sandbox don’t have that adversarial pull; you’re designing for the player rather than against them, and the work loops back on itself less destructively. The greenfield rewrite of the previous developer’s code base sounds painful but is the right call — clever-over-readable inherited code in a project that’s meant to be multiplayer is a long unwinnable fight.

The second part is the one I’ve been carrying around without saying out loud, and your post gives me the opening to say it. Since you brought up League specifically, I’ll be direct about it.

I played League of Legends obsessively, nearly every single day, for over ten years. I now consider that level of toxicity profoundly negative — for the people exposed to it and, at scale, for humanity. The realisation when I finally stopped was unambiguous: the game had done exactly one thing across that decade, which is produce negative effects at every level. On my brain. On my wife. On my marriage. On my children. On my behaviour in general. There is not a single upside I can put on the other side of the ledger that survives the audit.

The defence is always “it’s fine in a coordinated team” / “it depends on who you play with”. I played in teams, with friends, on voice. The level of toxicity and hate the game pulls out of you, independent of who’s around you, is the part most players cannot see while they’re still in it. They don’t realise they’ve become toxic — the medium has become invisible.

Psychologically, the addiction has structurally the same shape as casino gambling. Both work by hitting you with adrenaline spikes on an irregular reward schedule. The spike from a ranked match — when it lands — is on a different order of magnitude from anything you get from exercise. I’d estimate it at roughly ten times the dose. That’s why a workout afterwards feels flat, why a walk feels boring, why food tastes muted; nothing calibrated for normal life can compete with that artificial peak. You end up chasing it, and you stop showing up for everything else.

What you actually find out once you quit is the inverse. A smaller, slower dose of adrenaline — from sport, from being outside, from a conversation that goes somewhere — feels good again. Food tastes like food. I gained twelve kilos over the three years that followed, and that is exactly the point: I was eating like a normal person for the first time in a decade, instead of skipping meals to keep queueing. I started accepting invitations again. For years I had been turning down nights out to stay in front of the screen with people who, if they were being honest, would have had to describe what came out of the chat as a stream of hate — without quite hearing themselves say it.

There is one hard line, and I’m going to draw it here. A game cannot be called positive when it produces, as a routine side effect of its design, wishes that another player’s entire family die in atrocious suffering, that their children die too. That is what the chat looks like in normal ranked matches — not in fringe cases, in regular ones. You cannot build a design that consistently surfaces that, from players who would never type those words in any other context, and then defend the design as neutral. It isn’t neutral. The design caused it.

So yes — agreed on motivation. The day I closed that game for good is the day my output as a person, programmer, husband and father started going up again. Sandbox + cooperative + story-driven isn’t only a taste preference, it’s the only family of design I trust myself to ship without contributing to exactly the thing I just spent ten years climbing out of.

And yes I know: “sooo cliché”. But true. Well: True :slight_smile:

Two diagrams, in case the words above don’t land

Plain ASCII in fenced code blocks — this forum doesn’t have the Mermaid plugin enabled, so a rendered diagram wasn’t an option. Ugly, but portable.

1. Level 2 tutorial flow — frog ↔ player. No Tutorial class exists; every node below is either a DialogData resource or a flag in current_game_flags:

Player enters frog's ConversationProximitySensor
   │
   ▼
Router DialogData  (tuto_inventory_entry)  — branches on flags:
   │
   ├── no flags set
   │     └─► Intro     : frog mocks, orders "open your bag"
   │                     sets flag frog_tuto_intro_done
   │           │
   │           └── player walks away ──► Session ends
   │
   ├── frog_tuto_intro_done set AND tuto_opened_inventory_once unset
   │     └─► Nag       : random 1 of 3 variants ("we need to talk",
   │                     "why are you running", …)
   │           │
   │           └── player walks away ──► Session ends
   │
   └── tuto_opened_inventory_once set
         └─► Bravo     : RewardGiver gives the slip (ItemTransfer.move
                         frog → player), sets flag frog_tuto_done
               │
               └─► FlagDoorOpener watches frog_tuto_done
                   ──► exit door opens

Session ends ── player walks back into sensor ──► back to Router

External flag-setter (not part of the dialog graph):
   InventoryUI.open()  anywhere in the level
       └─► sets  tuto_opened_inventory_once  in current_game_flags
           └─► read by Router on the next visit

The thing worth seeing: the only branching logic is the router at the top. Everything else is data flowing between resources. Swap the gating flag, swap the reward, swap the dialog text — none of it touches code.

2. Chicken + mushroom in ambient chat, player walks in and joins mid-thread. Same Conversation session, three participants now, all symmetric:

T=0   Chicken and Mushroom are both inside each other's
      ConversationProximitySensor (Area2D + CollisionShape2D)
         │
         ▼
T=1   Chicken's ConversationBehaviorNpc calls
      Conversation.try_start(chicken, mushroom, "chitchat")
         │
         ▼
T=2   Conversation invites Mushroom → rules check ok → Mushroom joins
      Ambient session running. 2 participants.
      Player is NOT a participant — the dialog is happening, not
      addressed at him, no UI involvement.
         │
         ▼
T=3   Player walks into the UNION of chicken's + mushroom's sensors
      Player's ConversationBehaviorPlayer calls
      Conversation.request_join(...)
         │
         ▼
T=4   Conversation checks the rules resource for the session:
      rules.tags_that_can_start_dialog contains "player"?  yes
      Player joins MID-THREAD. 3 participants now.
      The DialogData graph continues from wherever it was —
      no rewind, no special "player just joined" branch.
         │
         ▼
T=5   Lines now can be addressed at any of the 3 participants.
      Player picks a DialogChoice, which may carry
      flag_changes: Array[SetFlagReward] applied before chaining.
         │
         ▼
T=6   Player walks out of the combined sensor coverage
      Conversation re-evaluates: is any peer still in the scope
      of any other peer?  no  →  session closes for ALL.
      Chicken and Mushroom drop the session at the same time.

The point both diagrams share, and the one I most wanted to convey: there is no code path that specifically handles “player tutorial” vs “ambient NPC chat”. They are the same machinery — ConversationProximitySensor + ConversationBehaviorNpc / ConversationBehaviorPlayer + DialogController + Conversation session — wired with different DialogData resources and different rules. The behaviour grows by adding data, not by branching code.

2 Likes

One follow-up I want to put down, addressed at the broader composition question this thread keeps circling.

From a developer’s seat, the machinery I’ve been describing — ConversationProximitySensor, ConversationProfile, ConversationBehaviorNpc, ConversationBehaviorPlayer, DialogController, RewardGiver, Conversation session, DialogData resources, the global flag dictionary, FlagDoorOpener, CoinObjective, Collectible, LifecycleState, NetworkCollectibleSync, and a few more I haven’t even named in this thread — is genuinely complex machinery. There’s a lot of pieces, and they have to interact coherently. When you sit down to extend the system, you have to hold the whole wiring in your head at once: which signal fires when, which flag gates what, which component owns which contract, which side of a multiplayer split a given concern lives on.

I want to be honest about that part. It is not trivially elegant. It is a lot of moving data points that have to fit together precisely for the thing to behave as one game rather than ten components shouting past each other.

But — and this is the part that actually matters — that complexity is a one-time learning cost, not a recurring one. Strictly a matter of training. Once your brain has integrated the moving parts, there is nothing more to learn. The curve is somewhat long, yes; the first months are confusing, yes; you’ll write three or four refactors before the shape clicks. But it’s a finite curve. You climb it, and then it stops climbing. Below the line, every new feature is the same handful of components rewired differently. Above the line, you have no new architecture left to figure out.

The reason that matters: once you’re across, there are effectively no more limits on what you can ship. Every “new” feature is a recombination of pieces you already have. And the kind of feature you can express that way is genuinely surprising.

To make that concrete, here are things that sound absurd but are genuinely possible with the components I already have, by composition alone, without writing any new system code:

  • A door that teleports the player somewhere else when it “opens”. StateDoor is a two-state machine — it doesn’t know what “open” means semantically. Drop a TeleportOnOpen sibling component that listens for the door’s opened signal and warps the player to a target marker. Same FlagDoorOpener you’d use for any other gate triggers it. Want it gated by collecting coins? Plug a CoinObjective. By an NPC’s blessing? A flag set from a dialog choice. By the in-game lunar phase? A custom flag-setter watching the world clock. The door itself does not care.

  • Chests that talk. A chest is an NPC at the resource level. Give it the entity_npc base + ParticipantTags (tag = “chest”) + a ConversationProximitySensor + a ConversationProfile with rules allowing the player to initiate — and the chest is now a participant in the dialog system. Its dialog graph hands out items via the same RewardGiver every NPC uses. The chest can refuse to open. It can negotiate. It can demand you talk to its cousin in another room first. It is, mechanically, indistinguishable from a chicken with a lid.

  • A coin you have to argue with before it lets itself be picked up. Collectible + ConversationBehavior on the same coin node. Walk into its proximity → dialog starts. You choose “please, just let me pick you up” → branches in the graph → eventually a choice carries a SetFlagReward that sets coin_42_agreed_to_be_picked. The coin’s Collectible._on_body_entered is gated on that flag. The coin is sentient. (Yes, this is silly. That’s the point — the engine does not care.)

  • A door that closes itself if the village votes against you. Each villager NPC carries a dialog with a “support the stranger” / “oppose the stranger” choice; each “oppose” choice carries a SetFlagReward setting villager_N_opposed. A FlagDoorCloser (symmetric sibling of FlagDoorOpener) watches the conjunction of those flags — when enough are set, the door slams. The voting system is the same dialog system. There is no VotingManager class anywhere; the votes are the flags.

  • A full tutorial delivered entirely by two NPCs talking to each other, while the player overhears and steps into the conversation. Chicken to mushroom: “have you seen the new one? still hasn’t worked out how to open the bag…”. Player walks into the union of their proximity sensors. Player’s ConversationBehaviorPlayer joins the session mid-thread. The chicken hands the slip over via the same RewardGiver, conversationally — “here, since you’re listening anyway”. No tutorial popup. No “Press TAB to open inventory” overlay. The teaching is narrative. Nothing in the codebase says “tutorial”.

  • A boss that quits the boss role mid-fight and walks over to your party. The boss has ParticipantTags set to [enemy, boss]. A dialog choice — yours, or another NPC’s intervening — carries a SetFlagReward swapping the tag set to [ally]. Every system that filters by tag (targeting, AI hate lists, friendly-fire predicates, the conversation rules resource) reads the new value on its next tick. Nothing else has to change. The boss is just a node carrying tags; you edited the tags.

  • An NPC that ages over the course of the game and starts answering your old questions wrong. The LifecycleState graph: young → adult → old. Each state points at a different ConversationProfile (different rules) and a different default_dialog_id. The lifecycle ticks on its own — could be in-game days, could be flag-triggered — and the dialog system simply reads whatever profile is current at conversation start. The NPC ages, and the conversation ages with him, with no “is_old” branching anywhere in the dialog code.

  • A trap that talks to you while you’re caught in it, taunts you, and only releases you if you flatter it. Trap is a node with a proximity volume that captures the player (sets a “player_caught” flag → blocks movement), plus a ConversationBehaviorNpc set to auto-start on capture, plus a RewardGiver that grants “freedom” (a flag that the movement system reads). Compliment dialog → flag set → release. The trap is, structurally, a chest that bites first and negotiates after.

The thing I keep coming back to is this: none of these need a new class. They are all rewires of components that exist for unrelated reasons in the project. The system itself stopped growing in lines of code roughly a year ago. What grows now is the count of .tres resources, the count of authored DialogData graphs, and the count of components dropped onto scenes in the editor. The engine for the game is, in the boring sense, finished.

That is the actual reward of crossing the long curve. The question stops being “how do I make the engine do this?” and becomes “do I want it to do this, and what would it mean for the player if I did?”. Which is the question you wanted to be asking in the first place, before composition forced you to learn a new instinct to get there.

3 Likes

Thanks for the ‘top tips’; I’m onto it. Have a splendid day. :slight_smile:

1 Like

Lol, this is as funny as it’s telling. Yeah, “indoctrination” sounds harsh but it really is what’s happening.

1 Like

Don’t tell me you’re trying to optimize for performance here :smiley:

I’d always opt for initializing via scene unique names but if it must be done via searching for nodes then all code snippets shown so far are quite pedestrian. You need to manually add initialization code whenever you add a component and you need to implement this initialization function in every class that uses components. Not very S O L I D :wink:

If you must do it that way, then implement a universal initializer in component base class:

class_name Component extends Node

static var component_classes: Array

static func _static_init():
	component_classes = ProjectSettings.get_global_class_list()
	component_classes = component_classes.filter(func(global_class): return global_class.base == &"Component" )
	component_classes = component_classes.map(func(global_class): return global_class.class)

static func find_and_assign_components(componentized_node: Node):
	var properties = componentized_node.get_property_list().filter(func(prop): return prop.usage & PROPERTY_USAGE_SCRIPT_VARIABLE and prop.class_name in component_classes)
	for prop in properties:
		var nodes = componentized_node.find_children("", prop.class_name, false, false)
		assert(nodes.size() == 1, "Number of %s components for %s must be exactly 1" % [prop.class_name, componentized_node])
		componentized_node.set(prop.name, nodes[0])

And just call it for each class that needs to assign its component variables:

Component.find_and_assign_components(self)

No additional code needed ever, just declare component vars and enjoy.

Je comprends. Mon français parlé est meilleur que mon français écrit.

is_in_group("player") vs typed Player parameter

I can envision what you’re saying, and it makes sense. If you can posses different entities as a player, then you need a different way to detect them.

I’m surprised you didn’t create a Player component and just look for that to determine if someone is a player, though I suppose looking in a group of one is a faster check than searching child nodes.

That makes sense and is something I’ve been trying to wrap my head around for the past day. I’m working on a game jam game, Rick O’Shea, which has driven me to new tutorials about third person controllers, and as I watch them I have been decomposing them in my head and re-imagining them as components. Making that a reality will be my task later today. (I actually linked to this thread in yesterday’s DevLog.)

I’m still missing something here. And this may be more of an implementation detail, but I don’t understand how the player walks in and just joins the conversation mid-thread. Do you design it with multiple nodes, and if the player joins, it just starts at whatever node the thread is currently at? Are they like swim lanes in the GraphEdit node? I’m having trouble conceptually wrapping my head around this one part.

These are very cool. It’s like having a bunch of Lego bricks and you can put them together to create things you never imagined. I came across the game Gravity Ace the other day, which is a 3.x Godot game with a built-in level editor. (The level editor is shown in the second video, and it’s really cool.) I was blown away. I found it through the creator’s YouTube tutorials and DevLogs.

I’m really inspired to re-think a lot of things through the lens of componentization.

This is where I want to be.

Back when I worked in early online games (late 90s to early 00s), we had a saying: “The player is always wrong.” When it came to balancing, every player opinion wasn’t about balance, it was about how that player felt their playstyle, class choice, or lack of ability was being treated unfairly. Balancing competitive games is a thankless task.

I found your discussion of playing League eye-opening. I had no idea it was that toxic. But the dopamine hit sounds like any other addiction. I’m curious how hard it was to quit. I imagine a lot of your identity was wrapped up in the game after 10 years.

I knew you were going to give me sh*t about this. LOL

You asking the question has honestly gotten me to rethink my stance on it - because I made this decision back when I was learning Godot and I was still in that over-optimization mindset so many of us experienced programmers come into Godot with. I fought against the idea of Autoloads for so long and insisted on making my own static singletons until I learned the benefits of Autoloads.

True. I think this is a result of me not really using components at scale yet, so I haven’t refactored that functionality.

I’m actually starting to lean towards what @surferix said:

The @export variables become the interface. And so instead of an annoyance, they become the way you wire something up. In something like my Camera2DComponent, the only thing it needs to know about is its parent, which is always a Camera2D. So much so, that it throws a configuration warning if it’s not added as a child of one.

@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 don’t know that there’s a need for a static helper for that kind of thing. In fact, I think it might be an anti-pattern. Which perhaps should have been my answer to @amarc. Because it might be good to try and make sure that Components don’t ever need to know about each other whenever possible. Things like a Poison status effect are an outlier, and might actually be better implemented not as a Node, but as a Resource that is applied to a Health component node.

@dragonforge-dev

I’m surprised you didn’t create a Player component … though I suppose looking in a group of one is a faster check than searching child nodes.

Your read is exactly right, and reason 1 from my last post is really the whole of it: there is no node that is permanently “the player” — there’s whatever body is player-controlled this frame. The speed difference is the minor part; the deciding part is that a group flips on and off cleanly and a class doesn’t.

I don’t understand how the player walks in and just joins the conversation mid-thread. Do you design it with multiple nodes, and if the player joins, it just starts at whatever node the thread is currently at? Are they like swim lanes in the GraphEdit node?

Let me unstick the picture, because the GraphEdit is the thing throwing you off — drop it from your mental model entirely. The GraphEdit is only an authoring convenience for laying out DialogData resources; it isn’t a runtime structure, nothing “runs on it”, and there are no lanes in it. (It’s also the part I said is still on the shelf — ignore it here, it has nothing to do with how a join works.)

There are two separate things, and conflating them is exactly the confusion:

1. The dialog graph — authoring time. Yes, multiple nodes: each node is one DialogData resource, the edges are next_dialog_ids. This is static data — a flowchart of lines. It has no concept of “who is in the room”. It only says “after line A comes line B or C”. Picture a paper flowchart, not lanes.

2. The conversation — run time. While a session is live there is exactly one Conversation object. It holds two things that matter here: the list of participants, and a single pointer to which DialogData is playing right now. That one pointer is the entire “where are we” state. It is not per-participant. Nobody owns a position — the session owns the position.

Now your guess — “if the player joins, it just starts at whatever node the thread is currently at?” — is exactly correct, and why it’s that simple falls straight out of (2): there is no per-participant cursor, so there is nothing to fast-forward. Joining is literally append the player to participants. The session’s single pointer didn’t move; it’s still on whatever DialogData was already playing. That’s why I said “no rewind, no special ‘player just joined’ branch” — there is no branch because nothing in the structure changed except the length of one array.

The swim-lane model breaks because it implies each participant has their own playhead moving through their own lane. There is one playhead for the whole session. A line of dialog doesn’t belong to a lane — it’s one DialogData, and it can name which participant it is aimed at. “Aimed at the player” is resolved at the moment that line plays, by asking the participant list who carries the “player” role. Player joined two lines ago? They’re in the list, the lookup finds them. Player never joined? The lookup finds nobody and that line simply isn’t reachable. No code anywhere asks “did the player join” — the participant list answers it implicitly.

AUTHORING (static data; the GraphEdit is just a way to draw this):

   [DialogData chitchat_1] ──▶ [chitchat_2] ──▶ [chitchat_3] ──▶ …
        one resource            one resource     (a line here can be
                                                  "aimed at: player")

RUNTIME (one object, no lanes):

   Conversation
     participants = [chicken, mushroom]     ← player walks in:
     current      = chitchat_2                 participants.append(player)
                                               current is STILL chitchat_2
                                               (nothing else happens)

   when chitchat_3 plays and it is "aimed at: player",
   the engine asks the participants list "who's the player?"
   and finds him, because he is now in that list. That is the join.

So, point by point: multiple nodes — yes, but only as authoring data. Starts at whatever node the thread is at — yes, exactly, because the position belongs to the session, not to the joiner. Swim lanes — no: one shared playhead, and participation is just membership in a list that tag-addressed lines query on the fly.

On League

“The player is always wrong” — that’s the same thing from the other side of the screen. The version I lived was the player being wrong about themselves: not seeing what the medium had already turned them into.

One correction to the timeline, because “10 years” makes it sound cleaner than it was. It wasn’t a solid decade — it was roughly 4–5 years genuinely in it, then about 5 more years of quitting and coming back, over and over. Toward the end the new friends I was starting to make outside the game treated it as a running gag — “reinstalled yet?” — and I usually had. I was the punchline before I finally stopped for good. So “how hard was it” has an honest answer: the first quit was trivial and meaningless; it took years of failed ones for the last one to hold.

The identity part you guessed is real, but it resolved oddly. I still watch other people play, regularly, and I enjoy it — not as a craving, the way you’d watch a concert pianist. At the top level the anticipation and execution are at an absurd standard and that is genuinely beautiful to watch. Losing the appreciation of the skill was never the problem. What I needed to lose was being inside the machine that manufactures the wish for a stranger’s family to suffer. You can keep the first and drop the second — they were never the same thing.

What finally made me quit for good was not the people. I even got the chance to play with adults who live near me, and — improbably — that went perfectly well. It was never the company that made me call the game toxic, or made me say it turns us, at our core, into something toxic. What ended it was actually reading the lines: a wish that my entire family — my wife, my children — die in atrocious suffering, from cancer. That surge of adrenaline and hatred rising into the brain of a kid who simply cannot control his emotions yet — and who, being a kid, is not to blame for that — is what made me realise I was burning my life on something that, in the end, meant nothing. The back-and-forth I mentioned was exactly this: every single time I came back, there was, without fail, a line that resembled, more or less, what I have just described. What disturbs me most is that some parents had raised certain children in a direction that makes you think humanity deserves the state it is in. If you can wish someone dead from behind a keyboard, just by pressing keys, then something purely theoretical and supposedly without consequences will inevitably scale up into real life. We have a French expression — “qui vole un œuf vole un bœuf” (“whoever steals an egg will steal an ox”): it only takes one small act to prove you can step over the line, and once you can, you can go toward things far larger and far more insane. I moved away from all of it. I took up board games, and I started using AI to push my projects much further than where I had left them stalled. I’m drifting well off the original topic now.

1 Like

It just introduces full automation by generalizing the node search. There’s nothing anti-pattern about it and there’s no components knowing anything about each other. The code could be hosted anywhere. It doesn’t even need to be static but it’s more convenient to be as it doesn’t need any object context. I just stuck it in there because semantically it somewhat belongs into Component namespace.

But if you want to write more repetitive code instead of no code at all, telling yourself you’re being anti-anti-patterns that way - well, it’s your prerogative.

1 Like

Well, you could add and remove it from the tree. I do this with states. All my State objects are atomic and do not depend on anything else. So if I remove one, then the StateMachine doesn’t know or care, neither do the other States, and on the rare occasion when I look to see if a State is running, it fails gracefully as a false if the State doesn’t exist. (Because all I’m doing is a Type check.)

Having said all that it seems like a very heavy approach unless you wanted to also attach additional code with that Node.

Ok, that makes sense now. Thank you for the explanation.

I learned a new French vocabulary word: “vole”.

I’m not a spectator, so even things I like doing I do not like watching others do them. I’m surprised after all the grief the game gave you, that you support it even as a spectator. Personally, I would want it out of my life.

I miscommunicated. Your solution makes sense. I think. What I’m saying is that the architectural pattern that causes the need for the code itself might be the anti-pattern. There was no disparagement meant on you or your code.

To be clear, I don’t want to write either code. But i also haven’t decided what I think this should look like in the future. And I might have a better answer for @amarc in the future.

1 Like

It doesn’t change the node based component architecture at all. It only reduces the amount of initialization code. You perhaps haven’t seen it done like that before. Very few people that promote node based components think about scalability. If you need it a lot in a project, you’ll end up with lots of repeating boilerplate. My example eliminates that.

1 Like

Agreed. I planned to keep it in mind as I continue to think about my future architecture. One of the benefits of the current game jam I’m in is seeing all the things I created that are failing, and the ones that are succeeding. (Which is why most of my plugins are still on a 0 major release.) I thought I could lean on my 3rd person controller, like I have before, and I realized that a 3rd person shooter camera and controller are completely different from an ARPG or platformer. So I just started from scratch.

1 Like

Reminds me of some of https://www.youtube.com/@Ombarus/ videos(
@Dad3353 maybe this would be a useful resource to check out).

Can’t remember exactly the video he talks about using json files in his game (mby this one https://www.youtube.com/watch?v=Cphf3XXyCJ0),

I also found this talk to be somewhat interesting and related to this post: https://www.youtube.com/watch?v=38gVgJj0eFQ

There is much to think about and process from what you said and I am sure most of the ides fly over my head.

For now ill just say thank you for sharing! =)