Hello. Last lesson established EngineCommand as the engine’s typed input language: Godot callbacks and tests may request actions, but only the pure Rust engine decides whether they are valid and how state changes.
This lesson defines the other half of that protocol: typed events. An event records what the engine determined happened after handling a command, including an expected contextual rejection. This gives the Godot adapter structured information to react to without inspecting or mutating core state, and it gives tests and later diagnostic tracing a stable vocabulary of observable results.
Events are facts; commands are requests
The distinction is grammatical and architectural:
| Protocol value | Tense | Meaning |
|---|---|---|
EngineCommand::AdvanceDialogue | Imperative | “Try to advance the dialogue.” |
EngineEvent::DialogueAdvanced | Past | “The dialogue advanced.” |
EngineEvent::CommandRejected(...) | Past | “The engine declined this kind of request for this typed reason.” |
A command is allowed to be invalid for the current state. For example, a callback can request AdvanceDialogue when the current dialogue instead requires a choice. That is not a crash and should not be silently ignored. It is an expected result of processing the request, represented explicitly as an event.
Rust’s enums are particularly effective for this because each event variant carries only the information that belongs to that outcome.
{"type":"video","title":"Rust Data Modelling Without Classes","learning_duration":136,"video_id":"z-0-bbc80JM","par_intro":"Watch “Rust Data Modelling Without Classes” by No Boilerplate for a concise reminder of why Rust enums model mutually exclusive states and why exhaustive matching matters at a protocol boundary.","par_directions":"Watch <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"a7153381\" data-range-start=\"43\" data-range-end=\"100\">the sum type model</span>, focusing on how variant-specific fields make invalid combinations unrepresentable. Then watch <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"9285e80d\" data-range-start=\"100\" data-range-end=\"179\">exhaustive matching</span> and connect it to the compiler identifying every Godot-side response that must be reconsidered when an engine event is added.","isV2":true,"blockId":"5d744f21-d5c6-4ade-a7c1-f91d00ddea33","lessonId":"6848f087-1f58-41a7-80e9-33e89fe1431a"}
Do not confuse engine events with either of these:
- Godot signals, such as a
Button’spressedsignal. A signal is a framework callback that the Rust adapter translates into anEngineCommand. - View models, such as a future
DialogueViewModel. A view model describes what presentation should display now. - Presentation instructions, such as
ShowButton,SetLabelText, orPlayFadeAnimation. Those are UI concerns and do not belong to the pure runtime protocol. - Persisted event sourcing. Events may later support diagnostic replay, but returning typed events does not commit this engine to event-sourced persistence. Save snapshots will remain a separately designed concern.
The useful boundary is: commands express intent; events expose the engine’s observable decisions; a view model projects current state for rendering.
{
"type": "exercise",
"id": "e80ce2ff-f3c1-4929-8584-f55bccc249db"
}
Design failures as typed outcomes too
A weak protocol often starts with an unstructured response:
struct EngineResult {
succeeded: bool,
message: Option<String>,
}
That representation immediately creates ambiguity:
- What does
succeeded: falsemean? - Which command was rejected?
- Is
"No dialogue"a player-facing localized string, a log message, or a condition an adapter should branch on? - Can
succeeded: truecarry an error-like message by mistake?
Instead, use named types. The Rust API Guidelines make the same point: primitive values such as booleans, integers, and optional values have many possible interpretations, whereas deliberate types encode the intended meaning and make future extension clearer.
{"type":"reading","par_intro":"Read “Type safety” from the Rust API Guidelines. Its first two subsections provide a useful design check for the event protocol: values whose meanings differ should have distinct types, rather than relying on generic flags or strings.","par_directions":"In “Newtypes provide static distinctions,” read <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"07161183\" data-range-start=\"Newtypes can statically distinguish between different interpretations of an\" data-range-end=\"we do not confuse them.\">the newtype rationale</span>. Then read the next subsection, “Arguments convey meaning through types, not bool or Option,” beginning at “Use a deliberate type” and ending at <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"882a8a3e\" data-range-start=\"Use a deliberate type (whether enum, struct, or tuple) to convey interpretation\" data-range-end=\"and invariants.\">the custom type guidance</span>. Apply the principle here: a rejection reason is structured domain information, not an optional text message.","learning_duration":"5 minutes","url":"https://rust-lang.github.io/api-guidelines/type-safety.html","title":"Type safety - Rust API Guidelines","isV2":true,"blockId":"73809cbc-c370-43c2-b29c-b59f525a1dc7","lessonId":"6848f087-1f58-41a7-80e9-33e89fe1431a"}
For this first vertical slice, model a failed dialogue action as a specific CommandRejection value. Avoid a shape such as this:
CommandRejected {
command: EngineCommand,
reason: CommandRejectionReason,
}
Although it looks reasonable, it permits incoherent combinations: StartNewGame paired with NoActiveDialogue, for example. The rejection itself should preserve which family of command was rejected, so its shape rules out that mismatch.
Define the initial event vocabulary
Create crates/vn_engine/src/event.rs:
use crate::command::EngineCommand;
/// An observable result produced while the engine handles a command.
///
/// Events are presentation-neutral facts. They contain no Godot objects,
/// scene paths, player-facing strings, or platform-specific data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineEvent {
/// A fresh game state was initialized from registered starting content.
GameStarted,
/// The active dialogue moved forward without selecting a choice.
DialogueAdvanced,
/// A currently offered dialogue choice was selected successfully.
///
/// This is a zero-based index in the engine-owned offered-choice list.
/// It will later become a stable typed choice identifier.
DialogueChoiceSelected {
option_index: u32,
},
/// The engine processed a command but declined to apply it in the
/// current state. This is an expected protocol outcome, not a panic.
CommandRejected(CommandRejection),
}
/// A contextual reason why one family of engine command was rejected.
///
/// Each variant identifies the command family it describes, preventing a
/// rejection reason from being paired with an unrelated command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandRejection {
AdvanceDialogue {
reason: AdvanceDialogueRejection,
},
ChooseDialogueOption {
option_index: u32,
reason: ChoiceRejection,
},
}
/// Reasons an advance request cannot currently change dialogue state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdvanceDialogueRejection {
NoActiveDialogue,
CurrentDialogueRequiresChoice,
DialogueHasEnded,
}
/// Reasons a request to select a dialogue option cannot succeed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChoiceRejection {
NoActiveDialogue,
NoChoicesAvailable,
/// The requested index does not exist in the currently offered list.
OptionOutOfRange {
available: u32,
},
/// The index exists, but that option is not presently selectable.
OptionUnavailable,
}
Then update the crate root:
// crates/vn_engine/src/lib.rs
mod command;
mod event;
pub use command::EngineCommand;
pub use event::{
AdvanceDialogueRejection,
ChoiceRejection,
CommandRejection,
EngineEvent,
};
The event payloads are deliberately small because the engine does not yet have stable IDs for nodes, choices, characters, locations, quests, or items. Do not fill that gap with String fields such as node_name or choice_text. In Module 2, the engine will define stable typed identifiers, and later event variants can carry those IDs as authoritative references.
For now, retaining option_index in DialogueChoiceSelected matches the current command protocol exactly. Its meaning remains engine-owned: it is not a Godot child position, button name, or scene-specific UI index.
Why CommandRejection is nested
The nested types encode meaningful restrictions:
let rejected = EngineEvent::CommandRejected(
CommandRejection::ChooseDialogueOption {
option_index: 4,
reason: ChoiceRejection::OptionOutOfRange { available: 2 },
},
);
This event says exactly what occurred: choice selection was attempted, option 4 was requested, and only two offered options existed.
By contrast, the type system will not let you attach AdvanceDialogueRejection::DialogueHasEnded to a ChooseDialogueOption rejection. The compiler is enforcing a small but real protocol invariant.
Use struct-like variants where field names matter at the call site and in diagnostics. OptionOutOfRange { available } is clearer than a bare tuple containing an unexplained u32. Use a tuple-like variant for CommandRejected because it wraps one self-describing rejection object rather than a set of independent fields.
{
"type": "exercise",
"id": "66e383af-a4e7-4f68-8a7f-493cec9c0e58"
}
One command may produce several events
When the deterministic runtime is implemented in Module 2, a single successful command may produce more than one fact. For example, choosing an option may record that the choice was selected and that the active dialogue entered a new node. A world activity may eventually consume time, change a stat, progress a quest, and add an item.
That is why the eventual processing boundary will return an ordered collection conceptually shaped like this:
fn handle(
state: &mut GameState,
command: EngineCommand,
) -> Vec<EngineEvent> {
// Implemented once the narrative runtime exists.
todo!()
}
Do not implement this handler yet: GameState, dialogue definitions, and controlled state mutation are upcoming work. The important decision today is that its results will be EngineEvent values, not booleans, raw strings, Godot Variants, or UI calls.
A well-behaved future handler should emit at least one event for every command it processes:
- A successful request emits one or more success facts.
- A contextually invalid request emits
CommandRejected(...). - A genuine engine defect, malformed compiled content, persistence failure, or Godot binding failure is not disguised as a rejection event. Those need a separate structured error taxonomy, introduced in the reliability module.
This distinction matters operationally. “The player tried to advance a terminal dialogue” is normal game behavior. “The registry contains duplicate content IDs” is an authored-content defect. The UI may quietly refresh or change available controls after the former; the latter must be diagnosed and surfaced through development tooling.
Keep events presentation-neutral
None of the following belongs in EngineEvent:
// Do not add variants like these.
ShowDialogue { text: String }
EnableChoiceButton { button_path: String }
PlayAnimation { animation_name: String }
GodotSignalReceived { signal_name: String }
Those variants would make vn_engine responsible for Godot scene structure, localization output, and animation policy. Instead, the Godot adapter will observe typed engine events, request an updated presentation-neutral view model, and render that model through Rust-owned Godot bindings.
The event enum itself also needs no serialization derive today. Later persistence will serialize validated save snapshots with explicit schema versions, while diagnostic traces will receive a deliberate compatibility policy. Being “data-shaped” is not sufficient reason to make a public type part of a long-term serialized format.
{
"type": "exercise",
"id": "e977f738-9f0b-46fb-a9a1-b9e0ea453285"
}
Test the public event protocol
Add crates/vn_engine/tests/event_api.rs:
use vn_engine::{
ChoiceRejection,
CommandRejection,
EngineEvent,
};
#[test]
fn out_of_range_choice_rejection_preserves_typed_context() {
let event = EngineEvent::CommandRejected(
CommandRejection::ChooseDialogueOption {
option_index: 5,
reason: ChoiceRejection::OptionOutOfRange { available: 2 },
},
);
match event {
EngineEvent::CommandRejected(
CommandRejection::ChooseDialogueOption {
option_index,
reason: ChoiceRejection::OptionOutOfRange { available },
},
) => {
assert_eq!(option_index, 5);
assert_eq!(available, 2);
}
other => panic!("unexpected event: {other:?}"),
}
}
This test does more than verify stored values. It checks that external crate users can construct and exhaustively match the public protocol. When you later add an event variant, compiler errors in the Godot adapter and tests will identify every place whose behavior needs an explicit decision.
Run the pure-Rust checks:
cargo fmt --check
cargo test -p vn_engine --locked
At this stage, verify these design constraints:
EngineEventand every rejection type live invn_engine.- All variants are facts or typed expected rejections, expressed in past-tense language.
- No event contains Godot node references, UI paths, presentation text, or platform data.
- No rejection is represented by a boolean or a string.
- No serialization policy has been accidentally committed through derives.
vn_godotmay match on these types, but it never defines competing string-tagged event objects.
Key takeaways
EngineEvent is the typed output vocabulary for command handling. It makes outcomes observable without allowing the presentation layer to infer, duplicate, or directly perform game-state changes.
For the current slice:
GameStarted,DialogueAdvanced, andDialogueChoiceSelectedrepresent successful facts.CommandRejected(CommandRejection)represents expected, state-dependent refusal with structured context.- Nested rejection enums prevent unrelated commands and reasons from being combined.
- Events are not Godot signals, UI instructions, view models, save data, or an automatic commitment to event sourcing.
- The event collection returned by a future handler can represent several ordered outcomes from one command.
Next, you will add smoke tests at both boundaries: one for the pure Rust engine crate and one proving that the Rust GDExtension loads correctly in Godot without any GDScript.
Can't find a good explanation? Sign up and we'll make it for you