Split Rust game src into modules; update AGENTS.md for dual toolchain

- Refactor apps/game/src/main.rs into modules: state.rs, camera.rs, ui/, gameplay/
- Add gameplay/galaxy_creation.rs scaffold (stubs only, no new logic)
- Update root AGENTS.md: separate TS workspace vs Rust game commands
- Update apps/AGENTS.md: docs/game/site use two toolchains, not one
- Add apps/game/AGENTS.md: build commands, module layout, naming conventions, Plugin pattern
This commit is contained in:
2026-06-04 10:00:43 -04:00
parent a1717e12db
commit a7796a1394
10 changed files with 337 additions and 195 deletions

78
apps/game/AGENTS.md Normal file
View File

@@ -0,0 +1,78 @@
# apps/game — Bevy/Rust Game Client
Single-player space exploration RPG built with [Bevy 0.16](https://bevyengine.org).
Crate name: `void-nav`.
This app is **independent of the pnpm/TS workspace** — no Vite, no React, no Tailwind. It builds with plain `cargo` from this directory.
## Commands
```bash
cargo run # Build + launch the game
cargo check # Fast typecheck (no codegen)
cargo build # Build without running
cargo clippy # Lint
cargo test # Run unit tests
```
## Module Layout
```
src/
├── main.rs # App builder: plugins, state init, system registration
├── state.rs # AppState enum (MainMenu, GalaxyCreation, CharacterCreation, InGame, Options)
├── camera.rs # Camera spawn
├── ui/ # UI screens (menus, HUD, etc.)
│ ├── mod.rs
│ └── main_menu.rs
└── gameplay/ # Non-UI gameplay systems
├── mod.rs
└── galaxy_creation.rs
```
### When to add a file vs a folder
- **Default**: one file per feature (e.g. `main_menu.rs`, `galaxy_creation.rs`).
- **Promote to a folder** when the file exceeds ~300 lines, mixes UI + logic + data, or needs shared private helpers.
- A promoted folder should expose its public API via `mod.rs` and ideally bundle its systems into a Bevy `Plugin` (see pattern below).
### Plugin pattern (recommended once a folder exists)
```rust
// src/gameplay/galaxy_creation/mod.rs
pub struct GalaxyCreationPlugin;
impl Plugin for GalaxyCreationPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::GalaxyCreation), setup_galaxy_creation)
.add_systems(OnExit(AppState::GalaxyCreation), despawn_galaxy_creation)
.add_systems(Update, (
/* update systems */
).run_if(in_state(AppState::GalaxyCreation)));
}
}
```
Then `main.rs` collapses to `app.add_plugins((..., GalaxyCreationPlugin))`.
## Naming Conventions
Follows [Rust RFC 344](https://rust-lang.github.io/api-guidelines/naming.html). Bevy layers these additional conventions:
| Item | Convention | Example |
|---|---|---|
| Files, modules, directories | `snake_case` | `galaxy_creation.rs`, `main_menu.rs` |
| Crate name | `snake_case` | `void-nav` |
| Structs, enums, enum variants | `UpperCamelCase` | `AppState`, `GalaxyCreation` |
| Components | `UpperCamelCase` (suffix optional) | `Player`, `MainMenuUi` |
| Resources | `UpperCamelCase` | `ClearColor`, `Time` |
| States | `UpperCamelCase` + `State` suffix | `AppState`, `GameState` |
| Plugins | `UpperCamelCase` + `Plugin` suffix | `GalaxyCreationPlugin` |
| Functions, systems, locals | `snake_case`, verb-first | `spawn_camera`, `setup_main_menu` |
| Constants, statics | `SCREAMING_SNAKE_CASE` | `MAX_HEALTH` |
## Architecture Notes
- **State machine driven**: each `AppState` variant has its own UI/systems wired via `OnEnter` / `OnExit` / `Update` (latter guarded by `in_state`).
- **Persistence**: SpacetimeDB will be the authoritative backend (via the Rust SDK, not the TS bindings). Not yet wired up.
- **No `module_bindings/` here**: TS bindings live in the TS workspace and are consumed by the docs prototype.

5
apps/game/src/camera.rs Normal file
View File

@@ -0,0 +1,5 @@
use bevy::prelude::*;
pub fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}

View File

@@ -0,0 +1,18 @@
use bevy::prelude::*;
// ── Markers ─────────────────────────────────────────────────────────────────
#[derive(Component)]
pub struct GalaxyCreationUi;
// ── Galaxy Creation Screen ──────────────────────────────────────────────────
pub fn setup_galaxy_creation(_commands: Commands) {
// TODO: spawn galaxy creation UI
}
pub fn despawn_galaxy_creation(mut commands: Commands, query: Query<Entity, With<GalaxyCreationUi>>) {
for entity in &query {
commands.entity(entity).despawn();
}
}

View File

@@ -0,0 +1 @@
pub mod galaxy_creation;

View File

@@ -1,176 +1,24 @@
mod camera;
mod gameplay;
mod state;
mod ui;
use bevy::prelude::*;
use gameplay::galaxy_creation;
use state::AppState;
use ui::main_menu;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.06)))
.init_state::<AppState>()
.add_systems(Startup, spawn_camera)
.add_systems(OnEnter(AppState::MainMenu), setup_main_menu)
.add_systems(OnExit(AppState::MainMenu), despawn_main_menu)
.add_systems(Update, main_menu_buttons)
.add_systems(Startup, camera::spawn_camera)
.add_systems(OnEnter(AppState::MainMenu), main_menu::setup_main_menu)
.add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu)
.add_systems(Update, main_menu::main_menu_buttons)
.add_systems(OnEnter(AppState::GalaxyCreation), galaxy_creation::setup_galaxy_creation)
.add_systems(OnExit(AppState::GalaxyCreation), galaxy_creation::despawn_galaxy_creation)
.run();
}
// ── States ──────────────────────────────────────────────────────────────────
#[derive(Clone, Eq, PartialEq, Debug, Hash, States, Default)]
enum AppState {
#[default]
MainMenu,
InGame,
Options,
}
// ── Markers ─────────────────────────────────────────────────────────────────
#[derive(Component)]
struct MainMenuUi;
#[derive(Component)]
enum MenuButton {
Start,
Options,
Exit,
}
// ── Camera ──────────────────────────────────────────────────────────────────
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
// ── Main Menu ───────────────────────────────────────────────────────────────
fn setup_main_menu(mut commands: Commands) {
let button_style = Style {
width: Val::Px(280.0),
height: Val::Px(64.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
let button_text_font = TextFont {
font_size: 28.0,
..default()
};
commands.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
display: Display::Flex,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(24.0),
..default()
},
BackgroundColor(Color::srgb(0.02, 0.02, 0.06)),
MainMenuUi,
)).with_children(|parent| {
// Title
parent.spawn((
Text::new("VOID::NAV"),
TextFont {
font_size: 72.0,
..default()
},
TextColor(Color::srgb(0.7, 0.85, 1.0)),
Node {
margin: UiRect::bottom(Val::Px(48.0)),
..default()
},
));
// Subtitle
parent.spawn((
Text::new("A space exploration RPG"),
TextFont {
font_size: 18.0,
..default()
},
TextColor(Color::srgb(0.4, 0.5, 0.6)),
Node {
margin: UiRect::bottom(Val::Px(32.0)),
..default()
},
));
// Start Game button
spawn_menu_button(
&mut parent.spawn_empty(),
"Start Game",
MenuButton::Start,
&button_style,
&button_text_font,
);
// Options button
spawn_menu_button(
&mut parent.spawn_empty(),
"Options",
MenuButton::Options,
&button_style,
&button_text_font,
);
// Exit button
spawn_menu_button(
&mut parent.spawn_empty(),
"Exit",
MenuButton::Exit,
&button_style,
&button_text_font,
);
});
}
fn spawn_menu_button(
cmd: &mut EntityCommands,
label: &str,
marker: MenuButton,
style: &Style,
text_font: &TextFont,
) {
cmd.insert((
Button,
style.clone(),
BackgroundColor(Color::srgb(0.08, 0.1, 0.18)),
BorderColor(Color::srgb(0.3, 0.45, 0.7)),
BorderRadius::all(Val::Px(8.0)),
marker,
))
.with_children(|btn| {
btn.spawn((
Text::new(label),
text_font.clone(),
TextColor(Color::srgb(0.75, 0.85, 1.0)),
));
});
}
fn despawn_main_menu(mut commands: Commands, query: Query<Entity, With<MainMenuUi>>) {
for entity in &query {
commands.entity(entity).despawn_recursive();
}
}
// ── Button Interaction ──────────────────────────────────────────────────────
fn main_menu_buttons(
mut next_state: ResMut<NextState<AppState>>,
mut exit: EventWriter<AppExit>,
interaction_query: Query<(&Interaction, &MenuButton), Changed<Interaction>>,
) {
for (interaction, button) in &interaction_query {
if *interaction == Interaction::Pressed {
match button {
MenuButton::Start => next_state.set(AppState::InGame),
MenuButton::Options => next_state.set(AppState::Options),
MenuButton::Exit => exit.send(AppExit::Success),
}
}
}
}

11
apps/game/src/state.rs Normal file
View File

@@ -0,0 +1,11 @@
use bevy::prelude::*;
#[derive(Clone, Eq, PartialEq, Debug, Hash, States, Default)]
pub enum AppState {
#[default]
MainMenu,
GalaxyCreation,
CharacterCreation,
InGame,
Options,
}

View File

@@ -0,0 +1,156 @@
use bevy::prelude::*;
use crate::state::AppState;
// ── Markers ─────────────────────────────────────────────────────────────────
#[derive(Component)]
pub struct MainMenuUi;
#[derive(Component)]
pub enum MenuButton {
ContinueGame, //Needed for later once save game functionality is implemented, should have a check to see if a save game exists and only show this button if it does
NewGame,
Options,
Exit,
}
// ── Main Menu ───────────────────────────────────────────────────────────────
pub fn setup_main_menu(mut commands: Commands) {
let button_style = Node {
width: Val::Px(280.0),
height: Val::Px(64.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
let button_text_font = TextFont {
font_size: 28.0,
..default()
};
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
display: Display::Flex,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(24.0),
..default()
},
BackgroundColor(Color::srgb(0.02, 0.02, 0.06)),
MainMenuUi,
))
.with_children(|parent| {
// Title
parent.spawn((
Text::new("VOID::NAV"),
TextFont {
font_size: 72.0,
..default()
},
TextColor(Color::srgb(0.7, 0.85, 1.0)),
Node {
margin: UiRect::bottom(Val::Px(48.0)),
..default()
},
));
// Subtitle
parent.spawn((
Text::new("A space exploration RPG"),
TextFont {
font_size: 18.0,
..default()
},
TextColor(Color::srgb(0.4, 0.5, 0.6)),
Node {
margin: UiRect::bottom(Val::Px(32.0)),
..default()
},
));
// Start Game button
spawn_menu_button(
&mut parent.spawn_empty(),
"Start Game",
MenuButton::NewGame,
&button_style,
&button_text_font,
);
// Options button
spawn_menu_button(
&mut parent.spawn_empty(),
"Options",
MenuButton::Options,
&button_style,
&button_text_font,
);
// Exit button
spawn_menu_button(
&mut parent.spawn_empty(),
"Exit",
MenuButton::Exit,
&button_style,
&button_text_font,
);
});
}
fn spawn_menu_button(
cmd: &mut EntityCommands,
label: &str,
marker: MenuButton,
style: &Node,
text_font: &TextFont,
) {
cmd.insert((
Button,
style.clone(),
BackgroundColor(Color::srgb(0.08, 0.1, 0.18)),
BorderColor(Color::srgb(0.3, 0.45, 0.7)),
BorderRadius::all(Val::Px(8.0)),
marker,
))
.with_children(|btn| {
btn.spawn((
Text::new(label),
text_font.clone(),
TextColor(Color::srgb(0.75, 0.85, 1.0)),
));
});
}
pub fn despawn_main_menu(mut commands: Commands, query: Query<Entity, With<MainMenuUi>>) {
for entity in &query {
commands.entity(entity).despawn();
}
}
// ── Button Interaction ──────────────────────────────────────────────────────
pub fn main_menu_buttons(
mut next_state: ResMut<NextState<AppState>>,
mut exit: EventWriter<AppExit>,
interaction_query: Query<(&Interaction, &MenuButton), Changed<Interaction>>,
) {
for (interaction, button) in &interaction_query {
if *interaction == Interaction::Pressed {
match button {
MenuButton::ContinueGame => next_state.set(AppState::InGame), // Placeholder for now
MenuButton::NewGame => next_state.set(AppState::GalaxyCreation),
MenuButton::Options => next_state.set(AppState::Options),
MenuButton::Exit => {
exit.write(AppExit::Success);
}
}
}
}
}

1
apps/game/src/ui/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod main_menu;