Introduction to Bevy Talks
[ⓘ] Be aware that
bevy_talks
's API is still undergoing revisions (with possibly big architectural changes). Feedback on its ergonomics and developer experience (DX) is highly appreciated.
This [Bevy][bevy] plugin provides a way to create dialogues and conversations in your game as graphs.
You can imagine a Talk between the player and NPCs as a directed graph where each node is an action that can be performed such as saying a line, joining/leaving the conversation, or a choice the player can make.
The most common action is text being displayed on the screen, and a simple Talk is just a sequence of texts forming a conversation between actors.
You can have multiple entities each with their own Talk graph. Or you can make a VN-like game with one single big dialogue graph in the game.
[!NOTE] A more in-depth documentation is being slowly written as an mdbook here! Help is appreciated :)
Actions and Actors
Talks are made up of actions that are translated into graph nodes.
Actions can be defined either via the TalkBuilder
(where you have more control over the dialogue graph with custom components and events) or with "talk.ron" asset files. With the latter, you are building dialogue nodes by passing Action
data:
#![allow(unused)] fn main() { struct Action { /// The ID of the action. id: ActionId, /// The kind of action. action: NodeKind, /// The actors involved in the action. actors: Vec<ActorSlug>, /// Any choices that the user can make during the action. choices: Option<Vec<Choice>>, /// The text of the action. text: Option<String>, /// The ID of the next action to perform. next: Option<ActionId>, } }
It contains several fields that define the kind of action it can be, the relevant actors, text or choices and the next action to perform (where to go in the graph after).
The actors are quite simple right now. It is just the name and an identifier (the slug):
#![allow(unused)] fn main() { struct Actor { /// The name of the actor. name: String, /// The unique slug of the actor. slug: ActorSlug, } }
Having a well defined Talk with actions and actors will result in spawning a graph where all the nodes are entities. Each action will be an entity "node", and each actor is also an entity.
All the action nodes will be connected with each other with an aery relationship (called FollowedBy), following the graph structure given by the actions next and id fields, and each action with actors will result in the corresponding entity being connected with the actors entities with another aery relationship (called PerformedBy).
The Parent Talk
All the node entities in the graph will be a child of a main entity that represents the Talk itself, with the Talk component attached to it.
You can think of this parent Talk entity as it "encapsulates" the graph and you can use it to identify a dialogue graph. You will use it to send events to advance the dialogue.
Build Talks from talk.ron files
The above-mentioned ron assets files are used to create TalkData
assets. They can be used to build dialogue graphs via bevy Commands
.
The files must have the extension: talk.ron
. Here's an example:
(
actors: [
( slug: "bob", name: "Bob" ),
( slug: "alice", name: "Alice" )
],
script: [
( id: 1, action: Talk, text: Some("Bob and Alice enter the room."), next: Some(2) ),
( id: 2, action: Join, actors: [ "bob", "alice" ], next: Some(3)),
( id: 3, actors: ["bob"], text: Some("Hello, Alice!"), next: Some(4) ), // without the action field, it defaults to Talk
(
id: 4,
choices: Some([
( text: "Alice says hello back.", next: 5 ),
( text: "Alice ignores Bob.", next: 6 ),
])
),
( id: 5, text: Some("Bob smiles."), next: Some(7)), // without the actors field, it defaults to an empty vector
( id: 6, text: Some("Bob starts crying."), next: Some(7) ),
( id: 7, text: Some("The end.") ) // without the next, it is an end node
]
)
The plugin adds an AssetLoader
for these ron files, so it's as easy as:
#![allow(unused)] fn main() { let handle: Handle<TalkData> = asset_server.load("simple.talk.ron"); }
Then you can use Talk::builder()
to create a TalkBuilder
, which has the fill_with_talk_data
method.
You can retrieve the TalkData
from the assets collection talks: Res<Assets<TalkData>>
.
With the builder ready, you can use the Commands extension to spawn the dialogue graph in the world:
#![allow(unused)] fn main() { use bevy::prelude::*; use bevy_talks::prelude::*; // We stored the previously loaded handle of a TalkData asset in this resource #[derive(Resource)] struct TalkAsset { handle: Handle<TalkData>, } fn spawn(mut commands: Commands, talks: Res<Assets<TalkData>>, talk_asset: Res<TalkAsset>) { let talk = talks.get(&talk_asset.handle).unwrap(); let talk_builder = TalkBuilder::default().fill_with_talk_data(simple_talk); // spawn the talk graph commands.spawn_talk(talk_builder, ()); } }
Spawning that talk graph will result in this:
graph LR;
A[Narrator Talks] --> B[Alice,Bob Join];
B --> C[Bob Talks];
C --> D[Choice];
D --> E[Narrator Talks];
D --> F[Narrator Talks];
F --> G[Narrator Talks];
E --> G;
Usage
Besides building dialogue graphs, at some point you have to interact with them.
After all the nodes are entities with components, so you could just do queries using the special CurrentNode
component that keeps track of the current node. Then each node could have a TextNode
, JoinNode
, LeaveNode
, ChoiceNode
or your own custom components (added via the builder).
Another way is to use a dialogue graph in an event-driven way. The plugin sends events every time you move to a new node based on the components it has. A node with a TextNode
will send a TextNodeEvent
event, a node with a ChoiceNode
will send a ChoiceEvent
event, and so on. You can also add your own node emitting components to customize the behaviour.
For example, to display the text of a TextNode
you can simply listen to the TextNodeEvent
event:
#![allow(unused)] fn main() { fn print_text(mut text_events: EventReader<TextNodeEvent>) { for txt_ev in text_events.read() { let mut speaker = "Narrator"; println!("{}", txt_ev.text); } } }
Note that the actors connected to the node are injected in the event, so you don't need to query them.
Request Events
That's the events from a dialogue graph to you. There is also the other direction so you can send requests to the dialogue graph (to advance the dialogue).
To move forward to the next action:
#![allow(unused)] fn main() { /// Event to request the next node in a `Talk`. /// It requires an entity with the `Talk` component you want to update. #[derive(Event)] pub struct NextNodeRequest { /// The entity with the `Talk` component you want to update. pub talk: Entity, } }
To jump to a specific action (used with choices):
#![allow(unused)] fn main() { /// An event to jump to some specific node in a graph. /// It requires an entity with the `Talk` component you want to update. #[derive(Event)] pub struct ChooseNodeRequest { /// The entity with the `Talk` component you want to update. pub talk: Entity, /// The next entity to go to. pub next: Entity, } }
There is also an useful event to re-send all the events associated to a node:
#![allow(unused)] fn main() { /// Event to request the current node to re-send all its events. #[derive(Event)] pub struct RefireNodeRequest { /// The entity with the `Talk` component you want to update. pub talk: Entity, } }
You pass the entity with the Talk
component in these events, plus the next node entity in case of the choose event.
Check out the examples
folder to see how to use the plugin.
- simple.rs shows how to use the plugin to create a simple, linear conversation.
- choices.rs shows how to use the plugin to create a conversation with choices (jumps in the graph).
- full.rs shows a Talk where all the action kinds are used.
- ingame.rs shows how to use the plugin with more than one talk you can interact with.
- custom_node_event.rs shows how to add your own event emitting component to create a custom node.
Roadmap
Some nice-to-haves from the top of my head:
- More node kinds (got rid of node kinds, now nodes are entities with components)
- Extensive documentation/manual wiki (added an mdbook, but always in progress...)
- Extensible Interaction/Trigger system (I mean I'm using events, more decoupled than this is impossible)
- Use the built-in bevy_ecs relations (when one day when we will have them)
- Dialogue UIs
- Graphical editor to create the asset files
- Voice lines/sound support
- Support other asset formats (yarn?)
- More examples
- Localization with Fluent
Bevy Version Support
Compatibility of bevy_talks
versions:
| bevy_talks
| bevy
|
| :-- | :-- |
| main
| 0.12
|
| 0.5.0
| 0.12
|
| 0.4.0
| 0.12
|
| 0.3.1
| 0.12
|
| 0.3.0
| 0.11
|
| 0.2.0
| 0.11
|
| 0.1.1
| 0.11
|
| bevy_main
| main
|
License
Dual-licensed under either of
- Apache License, Version 2.0, (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Getting Started
In this tutorial we will nstall Bevy Talks and do a quick overview on how to build and spawn a dialogue in your game.
Content
1. Installation
This plugin is compatible with Bevy 0.12 and is available on crates.io. To install it, add the following line to your Cargo.toml
file:
bevy_talks = "0.5"
or just run:
cargo add bevy_talks
2. Open the editor
That we don't have yet... one day... one can dream...
Just go to the next section :(
3. Create a talk
You have two ways to create a dialogue: via code or via a file.
If you want to do it via code and perhaps add custom stuff to your dialogue graphs, checkout the next chapter here: Creating Talks with TalkBuilder.
Otherwise, let's create a talk.ron
file in your assets
folder, let's call it hello.talk.ron
:
(
actors: [],
script: []
)
These files are made of two parts: the actors and the script. The actors are just a list of names and slugs (the identifier for an actor) and the script if a list of actions that can be performed (talk, choice, join and leave).
3.1 Talking
Let's add an actor (replace the actors
field with this):
actors: [
( slug: "bob", name: "Bob" )
],
Now let's add a talk action:
script: [
( id: 1, action: Talk, text: Some("Hello!"), actors: [ "bob" ], )
]
An action needs to have an id
so it can be referenced by other actions. The action
field is the type of action, in this case Talk
. It is not mandatory, if missing defaults to Talk
.
The text
field is the text that will be displayed in the dialogue box and needs to be wrapped in Some
when present.
Finally, the actors
field is a list of slugs of the actors performing the action. If missing, defaults to an empty list.
3.2 Joining
We could also add a Join
action before Bob starts talking to model the fact that he enters the room:
script: [
( id: 1, action: Join, actors: [ "bob" ], next: Some(2) ),
( id: 2, action: Talk, text: Some("Hello!"), actors: [ "bob" ], )
]
We had to add a next
field to the Join
action to tell the plugin which action to go to next. If missing, defaults to None
and the dialogue will end.
3.3 Leaving
Now let's send Bob away after his line:
script: [
( id: 1, action: Join, actors: [ "bob" ], next: Some(2) ),
( id: 2, text: Some("Hello!"), actors: [ "bob" ], next: Some(3)),
( id: 3, action: Leave, actors: [ "bob" ] )
]
Notice we can remove the action
field from the Talk
action, since it defaults to Talk
.
3.4 Choices
The plugin also supports player choices. This results in branching because the talk continues with the action chosen by the player.
script: [
( id: 1, action: Join, actors: [ "bob" ], next: Some(2) ),
( id: 2, text: Some("Hello!"), actors: [ "bob" ], next: Some(3) ),
( id: 3, action: Choice, choices: Some([
(text: "Hi Bob", next: 5),
(text: "I'm Alice's BF.", next: 4)
])),
( id: 4, action: Leave, actors: [ "bob" ] ),
( id: 5, text: Some(":)"), actors: [ "bob" ] ),
]
We added a Choice
action with two choices. In each choice the text
field is the text that you can display associated with a choice, and the next
field is the id of the action to go to next if the player chooses that option.
We also don't really need the action
field for the Choice action. If the choice vector is defined, it defaults to Choice
.
Notice that we didn't add the next
field to the last two actions. Any of the two choices will end the dialogue.
3.5 The Complete Talk
Here's the full talk.ron file:
(
actors: [
( slug: "bob", name: "Bob" ),
],
script: [
( id: 1, action: Join, actors: [ "bob" ], next: Some(2) ),
( id: 2, text: Some("Hello!"), actors: [ "bob" ], next: Some(3) ),
( id: 3, action: Choice, choices: Some([
(text: "Hi Bob", next: 5),
(text: "I'm Alice's BF.", next: 4)
])),
( id: 4, action: Leave, actors: [ "bob" ] ),
( id: 5, text: Some(":)"), actors: [ "bob" ], next: Some(2) ),
]
)
3.5.1 Loops
If you want to loop back, just use the next field:
script: [
( id: 1, action: Join, actors: [ "bob" ], next: Some(2) ),
( id: 2, text: Some("Hello!"), actors: [ "bob" ], next: Some(3) ),
...
( id: 5, text: Some(":)"), actors: [ "bob" ], next: Some(2) ),
]
4. Spawning the talk in your game
Now that we have a talk, let's add it to our game. To load the asset:
#![allow(unused)] fn main() { let h: Handle<TalkData> = asset_server.load("hello.talk.ron"); }
That creates a TalkData
asset. We need to store that handle so we can retrieve the actual TalkData and use it to spawn the action entities in the world:
#![allow(unused)] fn main() { #[derive(Resource)] struct MyTalkHandle(Handle<TalkData>); fn load_talks(mut commands: Commands, server: Res<AssetServer>) { let h: Handle<TalkData> = server.load("hello.talk.ron"); commands.insert_resource(MyTalkHandle(h)); } }
Now that we have the system that loads the talk, we need to spawn it in the world. We can do that in another system:
#![allow(unused)] fn main() { fn spawn_talk( mut commands: Commands, talks: Res<Assets<TalkData>>, talk_handle: Res<MyTalkHandle>, ) { let my_talk = talks.get(&talk_handle.0).unwrap(); let talk_builder = TalkBuilder::default().fill_with_talk_data(my_talk); // create a TalkBuilder with the TalkData commands.spawn_talk(talk_builder, ()); // spawn the graph with a commands extension } }
Alright! Now we have just spawned a graph of entities where each action is an entity with their own components. The actions performed by actors are also connected to the actors entities (just Bob in our case).
The entire graph is a child of a main entity with the Talk
component, you can use it to identify the graph in the world.
5. Displaying the talk
The plugin doesn't provide any UI system right now, so you can use whatever you want to display the dialogue. A dialogue graph sends you events everytime you move to a new node, so you can create small systems that listen to the different events.
#![allow(unused)] fn main() { fn print_text(mut text_events: EventReader<TextNodeEvent>) { for txt_ev in text_events.read() { let mut speaker = "Narrator"; if !txt_ev.actors.is_empty() { speaker = &txt_ev.actors[0]; } println!("{speaker}: {}", txt_ev.text); } } fn print_join(mut join_events: EventReader<JoinNodeEvent>) { for join_event in join_events.read() { println!("--- {:?} enters the scene.", join_event.actors); } } fn print_leave(mut leave_events: EventReader<LeaveNodeEvent>) { for leave_event in leave_events.read() { println!("--- {:?} exit the scene.", leave_event.actors); } } fn print_choice(mut choice_events: EventReader<ChoiceNodeEvent>) { for choice_event in choice_events.read() { println!("Choices:"); for (i, choice) in choice_event.choices.iter().enumerate() { println!("{}: {}", i + 1, choice.text); } } } }
The basics events are the TextNodeEvent
, JoinNodeEvent
, LeaveNodeEvent
and ChoiceNodeEvent
. They all have the actors
field to quickly access the actor names. In case of no actors (empty vector) we're defaulting to "Narrator".
6. Interacting with the talk
We spawned and are listening to the talk events, but we can't interact with it to move forward (or pick a choice).
To do that, the plugin has another kind of events: the "Request" events that you can send. Here the 2 that we will use: NextNodeRequest
and ChooseNodeRequest
. They both need the entity with the Talk
component you want to update, and for the ChooseNodeRequest
you also need to provide the entity of the next action to go to.
#![allow(unused)] fn main() { /// Advance the talk when the space key is pressed and select choices with 1 and 2. fn interact( input: Res<Input<KeyCode>>, mut next_action_events: EventWriter<NextNodeRequest>, mut choose_action_events: EventWriter<ChooseNodeRequest>, talks: Query<Entity, With<Talk>>, choices: Query<&ChoiceNode, With<CurrentNode>>, ) { let talk_ent = talks.single(); if input.just_pressed(KeyCode::Space) { next_action_events.send(NextNodeRequest::new(talk_ent)); } // Note that you CAN have a TextNode component and a ChoiceNode component at the same time. // It would allow you to display some text beside the choices. if choices.iter().count() == 0 { return; } let choice_node = choices.single(); if input.just_pressed(KeyCode::Key1) { choose_action_events.send(ChooseNodeRequest::new(talk_ent, choice_node.0[0].next)); } else if input.just_pressed(KeyCode::Key2) { choose_action_events.send(ChooseNodeRequest::new(talk_ent, choice_node.0[1].next)); } } }
To grab the Talk entity for the events is pretty easy, just query for it.
For the ChooseNodeRequest event we need access to the possible choices if the current node has the ChoiceNode
component. To grab them we can do a query on the special CurrentNode
that is attached only to the current node entity in a graph (note that if you have multiple dialogue graphs you will have multiple CurrentNode
s and you will have to filter them).
That's it!
The tutorial was based on the "full" example code in the examples folder. Also checkout the other examples, in particular the ingame one where 2 dialogue graphs are spawned and set as children (actually the Talk parent entity) of 2 interactable entities.
Next Steps
The plugin is being developed slowly but steady. Many many things are still missing or being experimented with. Hopefully in the following years as Bevy will shape up to a 1.0 this plugin will be the best dialogue system for it. :)
Creating Talks with TalkBuilder
You can build dialogue graphs programmatically using the TalkBuilder
.
[ⓘ] The
TalkBuilder
is also used under the hood to build the graphs from the asset files.
If you need to generate procedurally dialogue graphs, or you just don't like the asset files, you can use this approach. Otherwise use the talk.ron
files explained in the Getting Started chapter.
Simple Usage
Depending on your needs, building a dialogue graph via code can be more or less verbose. A simple, linear, conversation such as:
graph LR
A((Start)) --> B[Say]
B --> C[Say]
can be built with just a few lines of code:
#![allow(unused)] fn main() { let talk_builder = Talk::builder().say("Hello").say("World"); let talk_commands = commands.talks(); commands.spawn_talk(talk_builder); }
To actually spawn the entities with the relationships, you pass the TalkBuilder
to the Commands::spawn_talk
method, which will prepare a Command
to be added to the command queue.
The command, when applied, will first spawn the main parent entity of the graph with the Talk
component. Then add a start node with StartNode
component (the entry point of the graph) and finally spawn entities for each say
, choose
etc.
Usually the builder will connect the entities linearly based on the concatenated methods, with the only exception being the choose
method which is used for branching. In the example above you would have 3 entities each in a relationship with the next one (start -> say -> say), all children of the main Talk
entity.
You can check out all the methods that the builder provides in the API docs.
Build Branching Conversations
The simplest example would be a conversation with just 1 choice node:
graph LR
A((Start)) --> B[Say]
B --> C[Choice]
C --> D[Say]
C --> E[Say]
#![allow(unused)] fn main() { let talk_builder = Talk::builder(); talk_builder.say("How are you?") .choose(vec![ ("I'm fine", Talk::builder().say("I'm glad to hear that")), ("I'm not fine", Talk::builder().say("I'm sorry to hear that")), ]); }
The choose
method expects a vector of tuples. The first element is the text field of the choice (to be displayed) and the second is the branch of the conversation, which is another TalkBuilder
instance.
Multiple Branches
To make the example a bit more complex, let's say we have another choice in a branch:
graph LR
A((Start)) --> B[Say]
B --> C[Choice]
C --> D[Say]
C --> E[Say]
E --> F[Choice]
F --> G[Say]
F --> H[Say]
#![allow(unused)] fn main() { let talk_builder = Talk::builder(); let happy_branch = Talk::builder().say("I'm glad to hear that"); let sad_branch = Talk::builder() .say("Why?") .choose(vec![ ("Jk, I'm fine", Talk::builder().say("Aight")), ("I want an editor!", Talk::builder().say("Me too :(")) ]); talk_builder.say("How are you?") .choose(vec![("I'm fine", happy_branch), ("I'm not fine", sad_branch)]); }
It's easy to keep branching but it can get quite verbose and hard to read.
It is recommended to use the asset files for more complex conversations, but this can be useful if you want to quickly give some lines of texts to an item, or an NPC, or you are generating the conversation procedurally.
Connecting Nodes Manually
You can connect nodes manually with the connect_to
method. But you will need to have the node to connect to.
If for some reason we need a loop like this:
graph LR
A((Start)) --> B[Say]
B --> C[Say]
C --> B
#![allow(unused)] fn main() { let mut talk_builder = Talk::builder().say("Hello"); // grab latest node let node_a = talk_builder.last_node_id(); talk_builder ? talk_builder.say("World").connect_to(node_a); }
The node
method returns an identifier of the node, and we can use it to do manual connections.
Note you cannot create one node loops since currently self referential relationships are not supported.
You can also chain multiple connect_to
calls to connect multiple nodes to the same node.
Branching and Manual Connections
Suppose we want to build this conversation:
graph LR
A((Start)) --> B[Say]
B --> C[Say]
C --> D[Choice]
D --> E[Say]
D --> F[Say]
F --> B
Situations like this are somewhat common in games. You are talking to an NPC where only one choice lets you continue and the others are just some flavour text or some extra lore.
#![allow(unused)] fn main() { let mut talk_builder = Talk::builder().say("Hello"); // grab latest node let convo_start = talk_builder.last_node_id(); talk_builder = talk_builder .say("Hey") .choose(vec![ ("Good Choice", Talk::builder().say("End of the conversation")), ("Wrong Choice", Talk::builder().say("Go Back").connect_to(convo_start)) ]); }
Connecting To The Same Node
Imagine you want to land on a node from multiple places like this:
graph LR
A((Start)) --> B[Choice]
B --> C[Say]
C --> D[Choice]
D --> E[Say]
D --> F[Say]
E --> F
B --> F
You have an initial choice that can take the player to the end of the conversation, or go for some chat and then another choices which either goes to the end or passes by a talk node first.
You can think of that last talk node as its own branch that is pointed by multiple nodes.
#![allow(unused)] fn main() { let end_branch_builder = Talk::builder().say("The End"); // Create the end immediately let end_node_id = end_branch_builder.last_node_id(); // <- grab the end node // Create the good path let good_branch = Talk::builder().say("something").choose(vec![ ("Bad Choice", Talk::builder().connect_to(end_node_id.clone())), ( "Another Good Choice", Talk::builder().say("Before the end...").connect_to(end_node_id) ), ]); let builder = Talk::builder().choose(vec![ ("Good Choice", good_branch), // NB the builder is passed here. If we never add it and keep using connect_to // the end node would never be created ("Bad Choice", end_branch_builder) ]); }
Adding Actors to the mix
We saw the builder in action with just the say
method, but we can also have actors say stuff.
First we need to add the actors to the builder:
#![allow(unused)] fn main() { let mut talk_builder = Talk::builder() .add_actor("bob", "Bob") .add_actor("alice", "Alice"); }
Then we can use the actor_say
method (or actors_say
for multiple actors at once):
#![allow(unused)] fn main() { talk_builder = talk_builder.actor_say("bob", "Hello") .actor_say("alice", "Hi Bob"); }
The first argument is the actor slug. If the builder doesn't have an actor with that slug, it will panic when building. So always make sure to add the correct actors first. Also there is a actors_say
method that takes a vector of actors slug.
Actors can also "join" or "leave" the conversation. For that there are the relative methods join
and leave
:
#![allow(unused)] fn main() { talk_builder = talk_builder.add_actor("bob", "Bob") .join("bob") .actor_say("bob", "Folks, it do be me."); }
Node Event Emitters
The dialogue graph emits events when a node is reached. The way it does that is by using the NodeEventEmitter
trait for the node components that implement it.
#![allow(unused)] fn main() { /// Trait to implement on dialogue node components to make them emit an event when reached. #[bevy_trait_query::queryable] pub trait NodeEventEmitter { /// Creates an event to be emitted when a node is reached. fn make(&self, actors: &[Actor]) -> Box<dyn Reflect>; } }
In case of say
, choose
, join
and leave
the builder will spawn an entity and add the TextNode
, ChoiceNode
, JoinNode
and LeaveNode
components respectively. Each of these components implement the NodeEventEmitter
trait.
The idea is that you can create a Component
, implement the trait so you can create an Event
(optionally injecting the active actors) and then use that event to trigger some logic in your game.
You can check out the custom_node_event
example to see how to implement custom events. You will see that there is also a macro to help you with that and that you need to register the component (and event) with the app.register_node_event::<C, T>()
.
Custom Node Components
Related to the previous section, you can also add any custom components to a node with the with_component
method:
#![allow(unused)] fn main() { #[derive(Component, Reflect, Default)] #[reflect(Component)] struct MyCustomComponent { pub some: bool, } talk_builder = Talk::builder().say("Hello").with_component(MyCustomComponent::default()); }
This will add the component to the node entity, but remember to register the component type first with app.register_type::<MyCustomComponent>();
.
Going one step further, you can do a completely customized node by creating one empty first and then adding components to it:
#![allow(unused)] fn main() { let builder = Talk::builder().empty_node().with_component(MyCustomComponent::default()); }
You could create any kind of entity graph this way!