Skip to content
RustingEngineGitHub

Tutorial 3: Gameplay plugins

RustingEngine pages

Tutorials

All chapters

The rusting_game! API from Tutorial 1 is one function per frame. Real games want ECS systems: code that queries components, reads input, and runs at a chosen stage. This tutorial adds two systems to the Coin Run game from Tutorial 2:

  • Reset: pressing R puts the player back at the start.
  • Spin: a new coin_run.spin component, saved in the scene like any built-in one, that makes the flag spin.

The engine’s ECS is bevy_ecs. If you know Bevy, systems, queries, and resources work the same way.

1. Add the dependencies

Your game uses bevy_ecs types directly, and serde to save your component. Add both to Coin Run/Cargo.toml under [dependencies]:

toml
bevy_ecs = "0.19"
serde = { version = "1", features = ["derive"] }

Keep bevy_ecs on the same minor version as the engine (0.19), so both use one copy of the crate.

2. Write the plugin

Replace Coin Run/src/main.rs:

rust
use bevy_ecs::prelude::*;
use rusting_engine::prelude::*;
use rusting_engine::project_runner::{resolve_game_scene_path, run_project};
use rusting_engine::runtime::{
    ActionMap, App, AppError, InputBinding, KeyCode, Name, Plugin,
    RuntimeInput, ScheduleStage,
};
use serde::{Deserialize, Serialize};

/// Spins an object around its Z axis. Saved in scenes as `coin_run.spin`.
#[derive(Component, Clone, Debug, Default, Serialize, Deserialize)]
struct Spin {
    speed: f32,
}

// Describes Spin's fields for scenes, the Inspector, and `rusting schema`.
rusting_engine::reflect! {
    struct Spin {
        speed: f32 { unit: "rad/s", doc: "around the Z axis" },
    }
}

fn spin(time: Res<FrameTime>, mut objects: Query<(&Spin, &mut Transform)>) {
    for (spin, mut transform) in &mut objects {
        transform.rotation[2] += spin.speed * time.delta_seconds();
    }
}

/// Puts the player back at the start when `game.reset` is pressed.
fn reset_player(
    input: Res<RuntimeInput>,
    actions: Res<ActionMap>,
    mut objects: Query<(&Name, &mut Transform)>,
) {
    if !actions.just_pressed(&input, "game.reset") {
        return;
    }
    for (name, mut transform) in &mut objects {
        if name.0 == "Player" {
            transform.position = [-9.5, 0.5, 0.0];
        }
    }
}

struct CoinRunPlugin;

impl Plugin for CoinRunPlugin {
    fn build(&self, app: &mut App) -> Result<(), AppError> {
        app.register_scene_component::<Spin>("coin_run.spin")
            .map_err(|error| AppError::PluginSetup {
                plugin: self.name(),
                message: error.to_string(),
            })?;
        app.world_mut()
            .resource_mut::<ActionMap>()
            .bind("game.reset", InputBinding::Key(KeyCode::KeyR));
        app.add_systems(ScheduleStage::Update, (spin, reset_player));
        Ok(())
    }
}

fn main() -> GameResult {
    let scene = resolve_game_scene_path(
        "build/main.rscene.bin",
        env!("CARGO_MANIFEST_DIR"),
    );
    run_project("Coin Run", scene, CoinRunPlugin)
}

What each part does:

  • main replaces rusting_game!. resolve_game_scene_path finds the cooked scene next to an exported executable, or in the project during development. run_project takes a window title and your plugin; it also handles headless runs, scenario tests, and replays.
  • Plugin::build runs once at startup. It registers the component, binds the action, and adds the systems.
  • register_scene_component lets scenes store Spin under the name coin_run.spin. The type needs Component, Serialize, Deserialize, Default, and a reflect! description. Pick a prefix for your game; rusting. is the engine’s.
  • reflect! lists the fields scenes save, with optional hints: unit, min and max (floats), doc, and color: true for [f32; 3] or [f32; 4] colors. Mark #[serde(skip)] fields #[skip]. The macro checks the list against the struct, so a field added to one but not the other does not compile. Enums, nested structs, Vec, Option, string-keyed maps, Entity and Handle<TextureAsset> fields work too; an Entity saves as the target’s object ID and a handle as {"$asset": path} (or {"$data": value} for a unique data asset). A reference to an object that was deleted saves as null and loads as None in an Option<Entity>, or as Entity::PLACEHOLDER otherwise.
  • Actions. ActionMap::bind adds a binding to an action name, and just_pressed(&input, name) is true on the frame the key goes down. Also available: held and just_released. Binding more keys to the same action is fine; any of them triggers it.
  • Stages. Both systems run in Update, once per frame, reading FrameTime::delta_seconds(). Put logic that must replay exactly (anything that feeds physics) in ScheduleStage::FixedUpdate and use FrameTime::fixed_delta instead. See Core concepts.

3. Put the component in the scene

Find the flag’s ID and patch a coin_run.spin onto it:

bash
rusting scene query "Coin Run/scenes/main.rscene" --name Flag
json
{
  "operations": [
    {"op": "set", "id": "FLAG-ID", "path": "/components/coin_run.spin", "value": {"speed": 2.0}}
  ]
}
bash
rusting scene patch "Coin Run/scenes/main.rscene" spin.json
text
  Flag/components/coin_run.spin: null -> {"speed":2.0}
warning [PATCH_UNVALIDATED_COMPONENT]: component `coin_run.spin` is not built in; the game validates it on load

The CLI does not link your game code, so it cannot check your component’s fields. The game does, when it loads the scene: a wrong field or a component you forgot to register stops it with an error that names the component.

The editor and rusting capture also run without your code. They keep your components as saved data, so opening and saving the scene in the editor leaves coin_run.spin intact, but the Inspector does not show it yet.

4. Run and test it

bash
rusting run "Coin Run"

The flag spins. Walk right, press R, and the player is back at the start.

Scenarios press actions by name, including your own. Save this as Coin Run/reset.scenario.json:

json
{
  "name": "R puts the player back at the start",
  "seed": 1,
  "ticks": 60,
  "steps": [
    {"tick": 1, "press": "player.right"},
    {"tick": 40, "expect": {"entity": "Player", "path": "/transform/position/0", "greater_than": -8.0}},
    {"tick": 41, "press": "game.reset"},
    {"tick": 41, "expect": {"entity": "Player", "path": "/transform/position/0", "equals": -9.5, "tolerance": 0.01}},
    {"tick": 60, "expect": {"entity": "Flag", "path": "/transform/rotation/2", "greater_than": 1.0}}
  ]
}
bash
rusting test "Coin Run" "Coin Run/reset.scenario.json"
text
Scenario "R puts the player back at the start": passed after 60 ticks, seed 1

Going further

  • Other stages: Startup runs once before the first frame; PostUpdate runs after Update.
  • Resources: app.insert_resource(MyState::default()) in build, then Res<MyState> or ResMut<MyState> in a system.
  • Events: read a frame’s events with Res<EventQueue<CollisionEvent>> and .iter(). Other built-in events include HudButtonPressed, SoundEvent, and ClickEvent. Declare your own with app.add_event::<MyEvent>() and send them through ResMut<EventQueue<MyEvent>>.
  • Built-in components you can query: Transform, RigidBody, Collider, Counter, Pickup, HudElement, Tween, and the rest in rusting_engine::runtime. rusting schema lists their scene fields.
  • Renaming a field: scenes saved with the old name now fail to load with an error naming the component, the field’s path, and the saved version. Register the change instead: app.migrate_scene_component("coin_run.spin", FieldMigration::rename("speed", "turn_rate")) (or FieldMigration::remove("old_field")), from rusting_engine::reflect. Each migration raises the component’s version; newer saves record it as "$version", and older ones run the migrations they missed.
  • Random numbers: read the RandomSeed resource and draw with seed.value(tick, stream) or seed.unit(tick, stream), so a replay or scenario with the same seed gets the same numbers.

Next, Tutorial 4 moves thousands of bodies to the GPU.