@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:
- Approach → conversation auto-starts (proximity). Intro dialog plays, no reward yet — the frog tells the player to open their bag.
- Player walks away → session ends, no drama.
- Player returns before opening the inventory → flag
tuto_opened_inventory_onceis unset → router picks one of three “nag” variants at random. - Player opens the inventory anywhere in the level →
inventory_ui.gdsetstuto_opened_inventory_once. - Player returns after opening it → router picks the “bravo” dialog →
RewardGiverdoes anItemTransfer.moveof anItemStackfrom frog to player, and setsfrog_tuto_done. frog_tuto_donetriggers aFlagDoorOpenercomponent 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:
- In my game the controlled body can change at runtime — different characters, swapped pawns. There is no single
Playersubclass 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. - 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 ![]()
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.
