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
Rputs the player back at the start. - Spin: a new
coin_run.spincomponent, 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]:
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:
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:
mainreplacesrusting_game!.resolve_game_scene_pathfinds the cooked scene next to an exported executable, or in the project during development.run_projecttakes a window title and your plugin; it also handles headless runs, scenario tests, and replays.Plugin::buildruns once at startup. It registers the component, binds the action, and adds the systems.register_scene_componentlets scenes storeSpinunder the namecoin_run.spin. The type needsComponent,Serialize,Deserialize,Default, and areflect!description. Pick a prefix for your game;rusting.is the engine’s.reflect!lists the fields scenes save, with optional hints:unit,minandmax(floats),doc, andcolor: truefor[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,EntityandHandle<TextureAsset>fields work too; anEntitysaves 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 asnulland loads asNonein anOption<Entity>, or asEntity::PLACEHOLDERotherwise.- Actions.
ActionMap::bindadds a binding to an action name, andjust_pressed(&input, name)is true on the frame the key goes down. Also available:heldandjust_released. Binding more keys to the same action is fine; any of them triggers it. - Stages. Both systems run in
Update, once per frame, readingFrameTime::delta_seconds(). Put logic that must replay exactly (anything that feeds physics) inScheduleStage::FixedUpdateand useFrameTime::fixed_deltainstead. See Core concepts.
3. Put the component in the scene
Find the flag’s ID and patch a coin_run.spin onto it:
rusting scene query "Coin Run/scenes/main.rscene" --name Flag{
"operations": [
{"op": "set", "id": "FLAG-ID", "path": "/components/coin_run.spin", "value": {"speed": 2.0}}
]
}rusting scene patch "Coin Run/scenes/main.rscene" spin.json 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 loadThe 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
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:
{
"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}}
]
}rusting test "Coin Run" "Coin Run/reset.scenario.json"Scenario "R puts the player back at the start": passed after 60 ticks, seed 1Going further
- Other stages:
Startupruns once before the first frame;PostUpdateruns afterUpdate. - Resources:
app.insert_resource(MyState::default())inbuild, thenRes<MyState>orResMut<MyState>in a system. - Events: read a frame’s events with
Res<EventQueue<CollisionEvent>>and.iter(). Other built-in events includeHudButtonPressed,SoundEvent, andClickEvent. Declare your own withapp.add_event::<MyEvent>()and send them throughResMut<EventQueue<MyEvent>>. - Built-in components you can query:
Transform,RigidBody,Collider,Counter,Pickup,HudElement,Tween, and the rest inrusting_engine::runtime.rusting schemalists 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"))(orFieldMigration::remove("old_field")), fromrusting_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
RandomSeedresource and draw withseed.value(tick, stream)orseed.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.