Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Rusty Engine - Copilot Instructions

## Project Overview

Rusty Engine is a 2D game engine for learning Rust, built as a beginner-friendly wrapper around [Bevy](https://bevyengine.org/). It provides a simplified API for sprites, text, audio, input handling, collision detection, and game state management.

This is a **library crate** — there is no `main.rs`. Users create games by depending on this crate and using the `Game` struct as their entry point.

## Architecture

The codebase is flat — all modules live directly in `src/` with no subdirectories:

- **`lib.rs`** — Public API surface, prelude module, direction constants (UP, DOWN, LEFT, RIGHT, etc.)
- **`game.rs`** — Core `Engine` and `Game<S>` structs. `Engine` holds all game state (sprites, texts, input, audio, timing). `Game` wraps a Bevy `App` and provides `add_logic()` / `run()`.
- **`sprite.rs`** — `Sprite` struct (label, position, rotation, scale, collider) and `SpritePreset` enum (20 built-in sprites).
- **`text.rs`** — `Text` struct for on-screen text rendering.
- **`audio.rs`** — `AudioManager` with `play_sfx()` / `play_music()` / `stop_music()`. Includes `SfxPreset` (18 sounds) and `MusicPreset` (3 tracks).
- **`keyboard.rs`** — `KeyboardState` with pressed/just_pressed/just_released tracking and fluent `chain()` API.
- **`mouse.rs`** — `MouseState` with location, motion, buttons, and wheel state. Coordinates are game-space (positive x=right, positive y=up).
- **`physics.rs`** — Convex polygon collision detection using Separating Axis Theorem (SAT). `Collider` enum, `CollisionEvent`, `CollisionPair`.

### Key Design Pattern

The engine uses a **sync pattern** between user code and Bevy ECS:
1. Each frame, Bevy state is copied into the `Engine` struct
2. User logic functions receive `&mut Engine` and a mutable reference to a user-defined game state struct that the user marked with `#[derive(Resource)` and passed into `Game::run()`
3. After user logic runs, `game_logic_sync()` pushes changes back to Bevy entities

User-facing game logic functions always have the following signature, where `GameState` is the user-defined game state struct:
```rust
fn my_logic(engine: &mut Engine, game_state: &mut GameState) {
// game logic here
}
```

## Code Conventions

- **Prelude pattern**: All public types are re-exported via `rusty_engine::prelude::*`
- **Labels as identifiers**: Sprites and texts are stored in `bevy::utils::HashMap<String, Sprite/Text>` keyed by a unique label string
- **Constants**: Use `SCREAMING_SNAKE_CASE` (e.g., `ROTATION_SPEED`, `TEXT_DEFAULT_LAYER`)
- **Enums for presets**: Built-in assets use enums (`SpritePreset`, `SfxPreset`, `MusicPreset`) that implement `IntoIterator` and filepath conversion
- **Collider files**: Sprite colliders are stored as `.collider` files in RON format alongside sprite assets
- **Resource derive**: Game state structs must derive `#[derive(Resource)]` for Bevy compatibility
- **Doc comments**: Use `///` for public API documentation. Include usage examples in doc comments.
- **No `unwrap()` in library code** — prefer proper error handling or `expect()` with descriptive messages if the error is not recoverable.

## Dependencies

Key dependencies (keep versions aligned when updating):
- `bevy` — Core engine (selective features: audio, rendering, text, gamepad, GLTF)
- `bevy_prototype_lyon` — Shape rendering for collider visualization
- `ron` — RON format serialization for collider data
- `serde` — Serialization/deserialization for colliders
- `rand` — Dev dependency only, used in examples

Dependencies that start with `bevy_` are bevy plugins and need to be a version supported by the version of bevy we are currently on. The README.md file for these dependencies usually has a table indicating which version(s) are compatible with bevy version(s).

## Build, Test, and Lint

```bash
# Run tests
cargo test --workspace --all-targets --all-features

# Run clippy linter
cargo clippy --workspace --all-targets --all-features -- -D warnings

# Check formatting
cargo fmt --all -- --check

# Run a specific example
cargo run --release --example <name>
# Available examples: collision, collider, game_state, keyboard, layer,
# level_creator, mouse, music, placement, sfx, sound, sprite, text, window
```

Every change should be validated by running the example(s) that touch the change. If no example touches the change, then an existing example related to the change should be updated to touch the change or, for completely new features, a new example should be added.

Examples need to compile, run, and exit successfully. Additionally, examples need to behave correctly while running. In order to verify that examples run correctly, a human should be prompted that an example is going to be run, and then if it compiles, runs, and exits successfully then you should ask the human if the example worked correctly.

For changes that affect more than three examples, `script/test_examples` should be run to test all examples. Upon successful build and run of all examples, you should ask a human if all examples worked correctly.

CI runs on Ubuntu, macOS, and Windows. Linux requires `libasound2-dev` and `libudev-dev` system packages.

## Examples and Scenarios

- **`examples/`** — Runnable demos of individual engine features. Each has a doc comment explaining how to run it.
- **`scenarios/`** — Game programming challenges at varying difficulty (Easy → Insane). Include skeleton code and step-by-step instructions.
- **`tutorial/`** — mdBook-based tutorial covering all engine features, built with `mdbook`.

All examples, scenarios, and the tutorial documentation should be updated whenever the public API is changed.

## Release Process

Uses `cargo-release` configured via `release.toml`. Version replacements are automated across README.md, tutorial files, and CHANGELOG.md. See `RELEASE.md` for full instructions.

## Important Guidelines

- This engine is designed for **beginners learning Rust** — keep the API simple and approachable
- Avoid exposing raw Bevy types in the public API; wrap them in engine-specific types. Large enums used for input are an exception, and can be passed straight through from Bevy.
- All coordinates use a game-space system where positive x is right and positive y is up (origin at center)
- Sprite layers default to 0.0; text layers default to 900.0 (rendered on top)
- When adding new presets (sprites, sounds, music), add the corresponding assets to `assets/` and update the relevant enum
- Collider polygons must be convex for the SAT collision algorithm to work correctly
- The `Engine` struct fields are split into "SYNCED" (user-modifiable, synced back to Bevy) and "INFO" (read-only, populated from Bevy each frame) categories
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
<!-- next-header -->
## [Unreleased] - ReleaseDate

### Breaking changes

- The `KeyCode` enum (passed through from Bevy) has had many variants renamed. For example, `KeyCode::Key1` is now `KeyCode::Digit1`, `KeyCode::A` is now `KeyCode::KeyA`, and `KeyCode::Right` is now `KeyCode::ArrowRight`. Please refer to Bevy's [`KeyCode` documentation](https://docs.rs/bevy/0.18.0/bevy/input/keyboard/enum.KeyCode.html) for the full list of variants and their new names.
- Bevy's `WindowResolution::new` function now takes `u32`s instead of `f32`s
- Bevy's `Color` struct has had many changes. For example, `Color::rgb` is now `Color::srgb`. Please refer to Bevy's [`Color` documentation](https://docs.rs/bevy/0.18.0/bevy/color/enum.Color.html) for the full list of associated functions and their new names.


### Improved

- Update bevy from 0.12 to 0.18
- Update bevy_prototype_lyon from 0.10 to 0.16
- Update ron from 0.9 to 0.12
- Update rand from 0.8 to 0.9
- The tutorial has been updated to reflect the API changes and fix a few issues.

## [6.0.0] - 2023-12-03

### Breaking changes
Expand Down
27 changes: 10 additions & 17 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,33 @@ edition = "2021"
homepage = "https://github.com/CleanCut/rusty_engine"
repository = "https://github.com/CleanCut/rusty_engine"
readme = "README.md"
keywords = [ "game", "engine", "graphics", "audio", "rusty" ]
categories = [ "game-engines" ]
keywords = ["game", "engine", "graphics", "audio", "rusty"]
categories = ["game-engines"]
license = "MIT OR Apache-2.0"
exclude = [
"/assets",
"/.github",
"/scenarios",
"/tutorial",
"/script",
"release.toml",
"RELEASE.md",
".gitignore",
]
exclude = ["/assets", "/.github", "/scenarios", "/tutorial", "/script", "release.toml", "RELEASE.md", ".gitignore"]

[dependencies]
bevy = { version = "0.12.1", default-features = false, features = [
bevy = { version = "0.18", default-features = false, features = [
"bevy_audio",
"bevy_gilrs",
"bevy_gltf",
"bevy_render",
"bevy_sprite_render",
"bevy_text",
"bevy_winit",
"custom_cursor",
"png",
"hdr",
"mp3",
"x11",
"vorbis",
] }
bevy_prototype_lyon = "0.10.0"
ron = "0.8"
serde = { version = "1.0", features = [ "derive" ] }
bevy_prototype_lyon = "0.16.0"
ron = "0.12"
serde = { version = "1.0", features = ["derive"] }

[dev-dependencies]
rand = "0.8"
rand = "0.9"

[[example]]
name = "car_shoot"
Expand Down
35 changes: 16 additions & 19 deletions examples/collider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,31 +106,31 @@ fn main() {

fn game_logic(engine: &mut Engine, game_state: &mut GameState) {
// Zoom levels
if engine.keyboard_state.just_pressed(KeyCode::Key1) {
if engine.keyboard_state.just_pressed(KeyCode::Digit1) {
game_state.scale = 1.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key2) {
if engine.keyboard_state.just_pressed(KeyCode::Digit2) {
game_state.scale = 2.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key3) {
if engine.keyboard_state.just_pressed(KeyCode::Digit3) {
game_state.scale = 3.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key4) {
if engine.keyboard_state.just_pressed(KeyCode::Digit4) {
game_state.scale = 4.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key5) {
if engine.keyboard_state.just_pressed(KeyCode::Digit5) {
game_state.scale = 5.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key6) {
if engine.keyboard_state.just_pressed(KeyCode::Digit6) {
game_state.scale = 6.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key7) {
if engine.keyboard_state.just_pressed(KeyCode::Digit7) {
game_state.scale = 7.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key8) {
if engine.keyboard_state.just_pressed(KeyCode::Digit8) {
game_state.scale = 8.0;
}
if engine.keyboard_state.just_pressed(KeyCode::Key9) {
if engine.keyboard_state.just_pressed(KeyCode::Digit9) {
game_state.scale = 9.0;
}
// Update scale
Expand All @@ -143,7 +143,7 @@ fn game_logic(engine: &mut Engine, game_state: &mut GameState) {
}
// Delete collider
if engine.keyboard_state.just_pressed(KeyCode::Delete)
|| engine.keyboard_state.just_pressed(KeyCode::Back)
|| engine.keyboard_state.just_pressed(KeyCode::Backspace)
{
sprite.collider = Collider::NoCollider;
sprite.collider_dirty = true;
Expand All @@ -165,7 +165,7 @@ fn game_logic(engine: &mut Engine, game_state: &mut GameState) {
// Generate a circle collider
if engine
.keyboard_state
.just_pressed_any(&[KeyCode::Plus, KeyCode::Equals, KeyCode::NumpadAdd])
.just_pressed_any(&[KeyCode::Equal, KeyCode::NumpadAdd])
{
game_state.circle_radius += 0.5;
}
Expand All @@ -176,12 +176,11 @@ fn game_logic(engine: &mut Engine, game_state: &mut GameState) {
game_state.circle_radius -= 0.5;
}
if engine.keyboard_state.just_pressed_any(&[
KeyCode::Plus,
KeyCode::Equals,
KeyCode::Equal,
KeyCode::NumpadAdd,
KeyCode::Minus,
KeyCode::NumpadSubtract,
KeyCode::C,
KeyCode::KeyC,
]) {
sprite.collider = Collider::circle(game_state.circle_radius);
sprite.collider_dirty = true;
Expand All @@ -194,13 +193,11 @@ fn game_logic(engine: &mut Engine, game_state: &mut GameState) {
if convex.value != CONVEX_MESSAGE {
convex.value = CONVEX_MESSAGE.into();
}
} else {
if convex.value != NOT_CONVEX_MESSAGE {
convex.value = NOT_CONVEX_MESSAGE.into();
}
} else if convex.value != NOT_CONVEX_MESSAGE {
convex.value = NOT_CONVEX_MESSAGE.into();
}
// Write the collider file
if engine.keyboard_state.just_pressed(KeyCode::W) {
if engine.keyboard_state.just_pressed(KeyCode::KeyW) {
if sprite.write_collider() {
println!(
"Successfully wrote the new collider file: {}",
Expand Down
2 changes: 1 addition & 1 deletion examples/collision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ fn logic(engine: &mut Engine, _: &mut GameState) {
}

// Pressing C toggles sprite collider debug lines
if engine.keyboard_state.just_pressed(KeyCode::C) {
if engine.keyboard_state.just_pressed(KeyCode::KeyC) {
engine.show_colliders = !engine.show_colliders;
}
}
27 changes: 17 additions & 10 deletions examples/keyboard_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,29 @@ fn logic(engine: &mut Engine, _: &mut GameState) {
// Loop through any keyboard input that hasn't been processed this frame
for keyboard_event in &engine.keyboard_events {
if let KeyboardInput {
scan_code: _,
key_code: Some(key_code),
key_code,
state: ButtonState::Pressed,
window: _,
logical_key: _,
..
} = keyboard_event
{
// Handle various keypresses. The extra keys are for the Dvorak keyboard layout. ;-)
match key_code {
KeyCode::A | KeyCode::Left => race_car.translation.x -= 10.0,
KeyCode::D | KeyCode::Right | KeyCode::E => race_car.translation.x += 10.0,
KeyCode::O | KeyCode::Down | KeyCode::S => race_car.translation.y -= 10.0,
KeyCode::W | KeyCode::Up | KeyCode::Comma => race_car.translation.y += 10.0,
KeyCode::Z | KeyCode::Semicolon => race_car.rotation += std::f32::consts::FRAC_PI_4,
KeyCode::C | KeyCode::J => race_car.rotation -= std::f32::consts::FRAC_PI_4,
KeyCode::Plus | KeyCode::Equals => race_car.scale *= 1.1,
KeyCode::Minus | KeyCode::Underline => race_car.scale *= 0.9,
KeyCode::KeyW | KeyCode::ArrowUp | KeyCode::Comma => race_car.translation.y += 10.0,
KeyCode::KeyA | KeyCode::ArrowLeft => race_car.translation.x -= 10.0,
KeyCode::KeyS | KeyCode::ArrowDown | KeyCode::KeyO => {
race_car.translation.y -= 10.0
}
KeyCode::KeyD | KeyCode::ArrowRight | KeyCode::KeyE => {
race_car.translation.x += 10.0
}
KeyCode::KeyZ | KeyCode::Semicolon => {
race_car.rotation += std::f32::consts::FRAC_PI_4
}
KeyCode::KeyC | KeyCode::KeyJ => race_car.rotation -= std::f32::consts::FRAC_PI_4,
KeyCode::Equal => race_car.scale *= 1.1,
KeyCode::Minus => race_car.scale *= 0.9,
_ => {}
}

Expand Down
16 changes: 8 additions & 8 deletions examples/keyboard_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,32 @@ fn logic(engine: &mut Engine, _: &mut GameState) {

// Handle keyboard input
let ks = &mut engine.keyboard_state;
if ks.pressed_any(&[KeyCode::W, KeyCode::Up, KeyCode::Comma]) {
if ks.pressed_any(&[KeyCode::KeyW, KeyCode::ArrowUp, KeyCode::Comma]) {
race_car.translation.y += move_amount;
}
if ks.pressed_any(&[KeyCode::A, KeyCode::Left]) {
if ks.pressed_any(&[KeyCode::KeyA, KeyCode::ArrowLeft]) {
race_car.translation.x -= move_amount;
}
if ks.pressed_any(&[KeyCode::S, KeyCode::Down, KeyCode::O]) {
if ks.pressed_any(&[KeyCode::KeyS, KeyCode::ArrowDown, KeyCode::KeyO]) {
race_car.translation.y -= move_amount;
}
if ks.pressed_any(&[KeyCode::D, KeyCode::Right, KeyCode::E]) {
if ks.pressed_any(&[KeyCode::KeyD, KeyCode::ArrowRight, KeyCode::KeyE]) {
race_car.translation.x += move_amount;
}

// If you prefer a more functional style that is equivalent to the kind of logic above,
// but takes closures to run if the buttons are pressed, you can call `.chain()`
ks.chain()
.pressed_any(&[KeyCode::Z, KeyCode::Semicolon], |_| {
.pressed_any(&[KeyCode::KeyZ, KeyCode::Semicolon], |_| {
race_car.rotation += rotation_amount;
})
.pressed_any(&[KeyCode::C, KeyCode::J], |_| {
.pressed_any(&[KeyCode::KeyC, KeyCode::KeyJ], |_| {
race_car.rotation -= rotation_amount;
})
.pressed_any(&[KeyCode::Plus, KeyCode::Equals], |_| {
.pressed_any(&[KeyCode::Equal], |_| {
race_car.scale *= 1.0 + scale_amount;
})
.pressed_any(&[KeyCode::Minus, KeyCode::Underline], |_| {
.pressed_any(&[KeyCode::Minus], |_| {
race_car.scale *= 1.0 - scale_amount;
});

Expand Down
Loading
Loading