Create your own
Lesson illustration

Typed Command Enum for Engine Requests

Hello. In the previous lesson, you isolated desktop and web concerns inside vn_godot; the pure vn_engine crate remains platform-independent. Now we establish the next boundary: every request into that engine is represented as one Rust type.

This is the beginning of the engine’s application protocol. Godot UI callbacks, future mini-game scenes, and automated tests will all request game actions by constructing EngineCommand values. They will not reach into engine state or call arbitrary mutation methods. In the next lesson, the engine will gain the corresponding typed output vocabulary: events.


A command expresses intent, not a state mutation

A command says what some caller wants the engine to attempt. It does not assert that the action is valid, and it does not directly describe how the engine changes state.

For example, “advance the dialogue” is a valid request to make even when no dialogue is active. Whether that request can succeed depends on the current game state and authored content. The engine, rather than a Godot button handler, owns that decision.

The diagram places commands at the boundary of an imperative I/O shell and pure processing functions in the functional core. For this lesson, focus on the command entering the shell and being processed by pure Rust; the event and event-store portions preview the output boundary introduced next.

This distinction is especially valuable for your zero-GDScript engine:

LayerIts responsibilityWhat it must not do
Godot presentationTurn a click, key press, or scene interaction into a requestMutate narrative state directly
vn_engineValidate the request against state and apply deterministic rulesImport Godot types or inspect UI nodes
Compiled contentDefine the rules and definitions used by the engineReceive UI callbacks
Command enumCarry a typed statement of player or host intentCarry presentation objects or arbitrary strings

A command is therefore not “a method call serialized into a data structure.” It is a deliberately constrained part of the domain language.


Why an enum is the right protocol type

Rust enums model a closed set of alternatives. Each variant can carry exactly the data required for that one alternative, and match expressions force processing code to account for every variant.

Since you already use Rust, treat this as a focused review of the property that matters here: attached variant data avoids the invalid combinations that a generic command struct permits.

Defining an Enum - The Rust Programming Language

Read the Rust Programming Language book’s “Defining an Enum” chapter as a concise refresher on associated data and why a single enum type is preferable to several loosely related message structs.

On the “Defining an Enum” page, stay in the “Enum Values” discussion. Read associated variant data, noting that each variant may carry a different shape of payload. Then find the Message example and read the single message type. Focus on the fact that one function can receive every permitted request while Rust preserves the parameters particular to each request. You can skip the subsequent Option discussion.

The crucial design gain is not merely organization. Compare this weak representation:

enum CommandKind {
    StartNewGame,
    AdvanceDialogue,
    ChooseDialogueOption,
}

struct Command {
    kind: CommandKind,
    option_index: Option<u32>,
}

Rust permits both of these nonsensical states:

  • AdvanceDialogue with option_index: Some(3).
  • ChooseDialogueOption with option_index: None.

The type does not express the protocol’s rules. A tagged enum does:

pub enum EngineCommand {
    StartNewGame,
    AdvanceDialogue,
    ChooseDialogueOption { option_index: u32 },
}

Only ChooseDialogueOption can carry an index. The compiler makes the invalid pairings unconstructable.


Define the initial engine command vocabulary

Create crates/vn_engine/src/command.rs:

/// A request for the deterministic narrative engine to attempt an action.
///
/// A command expresses caller intent. Whether it is valid in the current
/// game state is decided by the engine when the command is processed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EngineCommand {
    /// Initialize a fresh game from the registered starting content.
    StartNewGame,

    /// Continue the currently active dialogue, if it can be advanced.
    AdvanceDialogue,

    /// Attempt to select one option from the current dialogue's offered choices.
    ///
    /// `option_index` is zero-based within the current engine-projected list
    /// of available options.
    ChooseDialogueOption {
        option_index: u32,
    },
}

Then expose the type from the engine crate’s public root:

// crates/vn_engine/src/lib.rs

mod command;

pub use command::EngineCommand;

The public use site will be intentionally unremarkable:

use vn_engine::EngineCommand;

let command = EngineCommand::ChooseDialogueOption { option_index: 1 };

At this stage, the small vocabulary is deliberate. It establishes the boundary without inventing APIs for quests, inventory, saving, world travel, or mini-games before those domains have state and validation rules.

Why these derives belong here

The derives give useful properties without coupling the engine to any framework:

  • Debug makes commands inspectable in tests and diagnostics.
  • Clone allows test fixtures or a future command queue to retain a copy without making cloning a requirement at every call site.
  • PartialEq and Eq allow exact assertions about requested actions.

Do not add Default. There is no safe or meaningful “default game action,” and a default value would make accidental requests easier to create.

Likewise, do not add serialization derives merely because the command is data. A save snapshot is not a command, and a future diagnostic replay format needs its own versioning and compatibility policy. We will make that decision when persistence and replay become concrete features.


Keep the vocabulary semantic and presentation-neutral

The names and fields in EngineCommand should describe game intent, not presentation mechanics.

These belong outside the pure command type:

  • NodePath, Gd<T>, Texture2D, Control, Callable, or any other Godot binding type.
  • A button’s Godot child index or a scene-specific widget name.
  • Raw player-facing text such as "Take the lantern".
  • Browser, desktop, threading, or file-system details.
  • A catch-all escape hatch such as Custom(String) or RunScript(String).

The command’s option_index deserves a precise interpretation. It refers to the zero-based position in the current engine-owned offered-choice list, not the position of a particular Godot Button node. The presentation layer may rebuild controls, use a different layout, or animate choices; none of that changes the engine protocol.

This index is intentionally a short-lived reference to the current interaction. In Module 2, compiled dialogue choices will gain stable typed identifiers. At that point, the command will evolve to carry a ChoiceId rather than an index. That removes any dependence on ordering and makes a command more robust for traces and tooling. For now, introducing a fake string identifier before the content model exists would only create a stringly typed placeholder.

A command can be structurally well-formed while still being invalid in context:

CommandExample contextual rejection
StartNewGameThe registered starting content is invalid or absent
AdvanceDialogueNo active dialogue can be advanced
ChooseDialogueOption { option_index: 4 }Fewer than five choices are currently available
ChooseDialogueOption { option_index: 1 }The second choice is currently unavailable due to state

The Godot layer should not replicate these checks. It may disable visibly unavailable controls for a better user experience, but the engine remains authoritative. A stale callback, an automated test, or a future alternate UI must receive the same deterministic validation.


Preserve one-way ownership at the boundary

The command type belongs to vn_engine, not vn_godot.

That location encodes the dependency direction:

vn_godot constructs EngineCommand values
vn_engine defines and processes EngineCommand values
vn_content supplies definitions used during processing

The pure engine owns the vocabulary because it is the only layer that can give each action consistent meaning. vn_godot is a client of that vocabulary. It can create EngineCommand::AdvanceDialogue after a Rust-connected Godot signal, but it must not define a competing UiCommand that later gets translated through strings, integer tags, or Godot Variant values.

The component-architecture pattern in the curated material is useful here because it begins with a component-specific request enum. Do not inherit its threaded control loop for this engine. A typed command does not require channels, threads, Send, or Sync. In fact, the same synchronous command-processing core must work in desktop builds, threaded web builds, and single-threaded web builds. A queue may be useful at the presentation boundary later, but it is an implementation choice, not part of the command definition.

For the same reason, keep command construction separate from command execution. A Rust callback should eventually construct and queue a command; the engine’s controlled update point will process it. This avoids re-entrant state changes when a presentation callback fires during rendering or animation.


Verify the boundary now

Add a small integration-style API test. It is modest, but it protects the crate-root re-export and the command’s basic value semantics:

// crates/vn_engine/tests/command_api.rs

use vn_engine::EngineCommand;

#[test]
fn choice_command_preserves_its_requested_option() {
    let command = EngineCommand::ChooseDialogueOption { option_index: 2 };

    assert_eq!(
        command,
        EngineCommand::ChooseDialogueOption { option_index: 2 }
    );
}

Then format and test the pure crate:

cargo fmt --check
cargo test -p vn_engine --locked

At this point, there should be no godot dependency in vn_engine/Cargo.toml, and command.rs should import no Godot APIs. That is the practical check that this is an engine protocol rather than a UI protocol.

A useful implementation checkpoint:

  • EngineCommand is defined in vn_engine.
  • Each action is a named enum variant, with associated fields only where that action needs them.
  • There are no string action names, optional “payload” fields, or untyped Godot values.
  • Commands request actions; they do not promise success.
  • No command processing implementation has been added yet, because its typed outputs belong to the next boundary.

Key takeaways

A typed command enum is the single input language for the deterministic engine. It gives Godot presentation code one safe way to request actions while keeping game-state mutation, validation, and rules inside pure Rust.

For the initial vertical slice:

  • StartNewGame and AdvanceDialogue are unit variants.
  • ChooseDialogueOption carries only the data that choice selection requires.
  • The enum’s closed set and variant-specific payloads rule out invalid command shapes at compile time.
  • Godot types, UI structure, and platform details remain outside vn_engine.
  • A valid command value may still be rejected by current state; the engine will own that judgment.

Next, you will define the typed event enum: the structured results emitted when the engine accepts, rejects, or otherwise processes these requests.

Can't find a good explanation? Sign up and we'll make it for you

Sign up