diff --git a/apps/docs/src/pages/docs/ArchitecturePage.tsx b/apps/docs/src/pages/docs/ArchitecturePage.tsx
index 51c1330..663b964 100644
--- a/apps/docs/src/pages/docs/ArchitecturePage.tsx
+++ b/apps/docs/src/pages/docs/ArchitecturePage.tsx
@@ -475,6 +475,130 @@ export function ArchitecturePage() {
accessibility audit as part of CI.
+ {/* ═══ CUSTOM MOVEMENT & COLLISION ═══ */}
+
+
+ ARCH-9
+
Custom Movement & Collision (No Physics Engine)
+
+
+
+ Decision: The Bevy game client uses a hand-rolled kinematic movement system and distance-based
+ collision detection. We do not integrate Rapier, Avian, XPBD, or any other rigid-body physics engine.
+ Raycasting and collision are implemented as pure geometry functions over circles in 2D.
+
+
+
Why No Physics Engine
+
+
+
+
Concern
Custom Kinematic
Physics Engine (Rapier/Avian)
+
+
+ {[
+ { c: 'Genre fit', custom: 'Native — FTL/Windward style is point-and-shoot, not rigid bodies.', engine: 'Overkill — solvers are for stacking, joints, real impulses.' },
+ { c: 'Arcade feel', custom: 'Native — drag, snap turns, no inertia when undesired.', engine: 'Fight the solver — disabling bounce/friction is config hell.' },
+ { c: 'Compile time', custom: '0 added', engine: '+30–90s on every clean build' },
+ { c: 'Binary size', custom: '0 added', engine: '+2–5 MB' },
+ { c: 'Determinism', custom: 'Trivial — linear math, no solver iterations.', engine: 'Hard — solver iterations + float ops in unpredictable order.' },
+ { c: 'Network prediction', custom: 'Linear extrapolation of owned values.', engine: 'Replaying the solver is a nightmare.' },
+ { c: 'Bug surface', custom: '~50 lines you wrote.', engine: 'Tens of thousands of lines you didn’t.' },
+ ].map((row, i) => (
+
+ Geometry functions are pure pub fns. Systems are thin Bevy wrappers that query entities and
+ call them. No third-party deps.
+
+
+
+
+
Scaling Tiers
+
+
+
+
Concurrent Entities
Strategy
Estimated Cost
+
+
+
{'< 500'}
Iterate all entities, linear scan.
Trivial — no broad-phase needed.
+
500 – 10,000
Flat array of (Entity, Vec2, radius), still linear.
Cache-friendly, single microsecond per query.
+
{'> 10,000'}
Add uniform grid or quadtree (≈50 LOC).
Out of scope for FTL-style combat density.
+
+
+
+
+
+ Escape hatch — when to reconsider: If destructible ship chunks that tumble, rotate, and stack
+ become a core visual, Rapier can be introduced only for debris entities while ships and projectiles
+ stay kinematic. This is additive: the custom movement/collision code does not need to be rewritten. The three
+ things a real solver would buy — stacking, joints, continuous CCD — are explicitly out of scope for arcade
+ space combat.
+
+
+
+ Network determinism note: Because movement and collision are pure functions of position,
+ velocity, and dt, every client given the same inputs produces the same outputs. This makes client-side
+ prediction and rollback against SpacetimeDB straightforward — a physics engine’s solver would make this
+ property very difficult to guarantee.
+
+
);
}
diff --git a/apps/game/src/camera.rs b/apps/game/src/camera.rs
index 2489ebf..6ef9dad 100644
--- a/apps/game/src/camera.rs
+++ b/apps/game/src/camera.rs
@@ -1,5 +1,102 @@
+use bevy::input::mouse::MouseButton;
use bevy::prelude::*;
+use bevy::window::PrimaryWindow;
-pub fn spawn_camera(mut commands: Commands) {
- commands.spawn(Camera2d);
+use crate::ui::util::cursor_over_ui;
+
+#[derive(Component)]
+pub struct MainCamera;
+
+/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
+/// Used for inspection scenes like GalaxyCreation where there is no player to follow.
+#[derive(Component, Debug, Clone, Copy)]
+pub struct OrbitCamera {
+ pub target: Vec3,
+ pub distance: f32,
+ /// Yaw around the Y axis (radians). 0 = camera at +Z looking toward origin.
+ pub yaw: f32,
+ /// Pitch above the horizontal plane (radians). 0 = horizontal, π/2 = straight down.
+ pub pitch: f32,
+}
+
+impl Default for OrbitCamera {
+ fn default() -> Self {
+ Self {
+ target: Vec3::ZERO,
+ distance: 420.0,
+ yaw: 0.0,
+ // ~36° above horizontal — roughly matches docs GalaxyScene camera position.
+ pitch: 0.625,
+ }
+ }
+}
+
+/// Initial camera spawn. The same entity is reused across states; control systems
+/// decide how it moves depending on which state is active.
+pub fn spawn_camera(mut commands: Commands) {
+ commands.spawn((
+ Camera3d::default(),
+ Transform::from_xyz(0.0, 260.0, 360.0).looking_at(Vec3::ZERO, Vec3::Y),
+ MainCamera,
+ OrbitCamera::default(),
+ ));
+}
+
+const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
+const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
+const ORBIT_MIN_PITCH: f32 = 0.15; // ~9° above horizontal — never fully edge-on
+const ORBIT_MAX_PITCH: f32 = 1.4; // ~80° — never straight down (gimbal safeguard)
+const ORBIT_MIN_DISTANCE: f32 = 40.0;
+const ORBIT_MAX_DISTANCE: f32 = 1500.0;
+
+/// Left-drag rotates around the orbit target; scroll wheel zooms.
+///
+/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
+/// buttons or panels would also rotate the camera.
+pub fn orbit_camera_control(
+ mouse_input: Res>,
+ primary_window: Query<&Window, With>,
+ mut mouse_motion: EventReader,
+ mut scroll_events: EventReader,
+ mut query: Query<(&mut Transform, &mut OrbitCamera), With>,
+ ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
+) {
+ let Ok((mut transform, mut orbit)) = query.single_mut() else {
+ // Drain pending input to avoid stale buildup when there's no camera.
+ mouse_motion.clear();
+ scroll_events.clear();
+ return;
+ };
+
+ let cursor_over_ui = primary_window
+ .single()
+ .ok()
+ .map(|w| cursor_over_ui(w, &ui_nodes))
+ .unwrap_or(false);
+
+ if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
+ for event in mouse_motion.read() {
+ orbit.yaw -= event.delta.x * ORBIT_ROTATE_SENSITIVITY;
+ orbit.pitch -= event.delta.y * ORBIT_ROTATE_SENSITIVITY;
+ }
+ } else {
+ mouse_motion.clear();
+ }
+
+ for event in scroll_events.read() {
+ // Scroll up (positive y) → decrease distance (zoom in).
+ orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
+ }
+
+ orbit.pitch = orbit.pitch.clamp(ORBIT_MIN_PITCH, ORBIT_MAX_PITCH);
+ orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
+
+ let cos_p = orbit.pitch.cos();
+ let pos = orbit.target
+ + Vec3::new(
+ orbit.distance * cos_p * orbit.yaw.sin(),
+ orbit.distance * orbit.pitch.sin(),
+ orbit.distance * cos_p * orbit.yaw.cos(),
+ );
+ *transform = Transform::from_translation(pos).looking_at(orbit.target, Vec3::Y);
}
diff --git a/apps/game/src/gameplay/galaxy_creation.rs b/apps/game/src/gameplay/galaxy_creation.rs
deleted file mode 100644
index 46442ff..0000000
--- a/apps/game/src/gameplay/galaxy_creation.rs
+++ /dev/null
@@ -1,18 +0,0 @@
-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>) {
- for entity in &query {
- commands.entity(entity).despawn();
- }
-}
diff --git a/apps/game/src/gameplay/galaxy_creation/mod.rs b/apps/game/src/gameplay/galaxy_creation/mod.rs
new file mode 100644
index 0000000..db8ec7e
--- /dev/null
+++ b/apps/game/src/gameplay/galaxy_creation/mod.rs
@@ -0,0 +1,369 @@
+//! Galaxy creation inspection scene.
+//!
+//! Procedural spiral galaxy viewer with editable parameters. The 3D scene is
+//! regenerated whenever [`GalaxyParams`] changes (via the `generation` counter);
+//! click-to-select is handled in [`super::selection`]; the slider/info panels
+//! live in [`super::ui`].
+
+mod params;
+mod selection;
+mod ui;
+
+use bevy::prelude::*;
+use rand::rngs::StdRng;
+use rand::{Rng, SeedableRng};
+
+use crate::state::AppState;
+
+pub use params::{GalaxyParams, SelectedStar};
+use params::{CORE_COUNT, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
+
+pub struct GalaxyCreationPlugin;
+
+impl Plugin for GalaxyCreationPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::()
+ .init_resource::()
+ .add_systems(OnEnter(AppState::GalaxyCreation), (setup_galaxy_scene, ui::setup_galaxy_ui))
+ .add_systems(OnExit(AppState::GalaxyCreation), (despawn_galaxy_creation, reset_selection))
+ .add_systems(
+ Update,
+ (
+ escape_to_main_menu,
+ ui::param_button_handler,
+ ui::refresh_control_panel_values,
+ regenerate_galaxy_on_param_change,
+ selection::select_star_on_click,
+ selection::animate_selected_star,
+ selection::refresh_info_panel,
+ )
+ .chain()
+ .run_if(in_state(AppState::GalaxyCreation)),
+ );
+ }
+}
+
+// ── Markers ─────────────────────────────────────────────────────────────────
+
+/// Tag for *anything* spawned during galaxy creation so it can be cleanly
+/// despawned on state exit. Applies to both 3D scene roots and UI panel roots.
+#[derive(Component)]
+pub struct GalaxyCreationSpawned;
+
+/// Tag for the 3D scene root only — despawned on regeneration, so we can
+/// rebuild the galaxy without disturbing the UI panels.
+#[derive(Component)]
+pub struct GalaxyScene;
+
+/// A star system in the procedural galaxy.
+#[derive(Component, Debug)]
+pub struct StarSystem {
+ pub id: String,
+ pub name: String,
+ pub faction: &'static str,
+ pub security: f32,
+}
+
+// ── Faction palette (matches docs GalaxyScene) ──────────────────────────────
+
+const FACTIONS: &[(&str, [f32; 3])] = &[
+ ("Concord", [0.13, 0.83, 0.93]), // cyan — core faction
+ ("Amarr", [0.96, 0.62, 0.04]), // amber
+ ("Minmatar", [0.94, 0.27, 0.27]), // red
+ ("Gallente", [0.66, 0.33, 0.97]), // purple
+ ("Caldari", [0.22, 0.74, 0.97]), // blue
+];
+
+// ── Setup ───────────────────────────────────────────────────────────────────
+
+fn setup_galaxy_scene(
+ mut commands: Commands,
+ mut meshes: ResMut>,
+ mut materials: ResMut>,
+ params: Res,
+) {
+ let systems = generate_galaxy(¶ms);
+ spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
+}
+
+/// Generate the full galaxy layout (pure data — no Bevy types).
+///
+/// Faithful port of the TS reference in
+/// `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
+fn generate_galaxy(params: &GalaxyParams) -> Vec {
+ let mut rng = StdRng::seed_from_u64(params.seed);
+ let mut systems: Vec = Vec::with_capacity(params.count);
+
+ // Spacing scales with density: smaller for tight cores, larger spread overall.
+ let base_spacing = (params.size / (params.count as f32).sqrt() * 1.25).clamp(9.0, 24.0);
+ let horizontal_arms = params.arms.max(1);
+ let vertical_arms = params.vertical_arms;
+ let total_arm_slots = horizontal_arms + vertical_arms;
+
+ for index in 0..params.count {
+ let core = index < CORE_COUNT;
+ let arm_slot = (index as u32) % total_arm_slots.max(1);
+ let vertical = !core && arm_slot >= horizontal_arms;
+ let arm = if vertical {
+ arm_slot - horizontal_arms
+ } else {
+ arm_slot
+ };
+ let faction_index = if core {
+ 0 // Concord
+ } else {
+ ((arm_slot as usize) % (FACTIONS.len() - 1)) + 1
+ };
+ let (faction, color) = FACTIONS[faction_index];
+
+ let mut position = Vec3::ZERO;
+ let mut final_radius = 0.0f32;
+ for attempt in 0..SPACING_ATTEMPTS {
+ // `pow(0.62)` produces a density bias toward the core.
+ let r = if core {
+ 8.0 + rng.gen::() * 30.0
+ } else {
+ rng.gen::().powf(0.62) * params.size
+ };
+ let arm_count = if vertical { vertical_arms.max(1) } else { horizontal_arms };
+ let arm_twist = if vertical { params.vertical_twist } else { params.twist };
+ let angle = if core {
+ rng.gen::() * std::f32::consts::TAU
+ } else {
+ std::f32::consts::TAU * arm as f32 / arm_count as f32
+ + (r / params.size) * arm_twist
+ + (rng.gen::() - 0.5) * 0.72
+ };
+ let (x, y, z) = if vertical {
+ // Vertical arms: stars orbit in a vertical plane rotated around the Y axis.
+ let plane_angle = std::f32::consts::TAU * arm as f32 / vertical_arms.max(1) as f32;
+ let arm_sweep = angle.cos() * r;
+ let vx = plane_angle.cos() * arm_sweep;
+ let vz = plane_angle.sin() * arm_sweep;
+ let vy = angle.sin() * r * 0.72 + (rng.gen::() - 0.5) * 12.0;
+ (vx, vy, vz)
+ } else {
+ (angle.cos() * r, (rng.gen::() - 0.5) * 20.0, angle.sin() * r)
+ };
+ let candidate = Vec3::new(x, y, z);
+ final_radius = r;
+
+ // Spacing relaxation: shrink required distance after many failed attempts.
+ let relaxed = if attempt > 120 {
+ base_spacing * 0.68
+ } else if attempt > 60 {
+ base_spacing * 0.82
+ } else {
+ base_spacing
+ };
+ let local_spacing = if core { relaxed * 0.9 } else { relaxed };
+ let clear = systems
+ .iter()
+ .all(|s| s.position.distance(candidate) >= local_spacing);
+ if clear {
+ position = candidate;
+ break;
+ }
+ }
+
+ let security =
+ ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0;
+ let name = if core {
+ format!("COR-{}", 100 + index)
+ } else {
+ format!("{}-{}", &faction[..3].to_uppercase(), 100 + index)
+ };
+
+ systems.push(GeneratedSystem {
+ id: format!("g-{index}"),
+ name,
+ position,
+ faction,
+ faction_index,
+ color,
+ security,
+ });
+ }
+
+ systems
+}
+
+struct GeneratedSystem {
+ id: String,
+ name: String,
+ position: Vec3,
+ faction: &'static str,
+ faction_index: usize,
+ /// Per-system tint; currently the renderer uses per-faction materials indexed
+ /// by `faction_index`, but this is kept for future per-system variation.
+ #[allow(dead_code)]
+ color: [f32; 3],
+ security: f32,
+}
+
+fn spawn_galaxy_scene(
+ commands: &mut Commands,
+ meshes: &mut Assets,
+ materials: &mut Assets,
+ systems: &[GeneratedSystem],
+) {
+ // Shared meshes (Bevy 0.16 required-components model — no bundle needed).
+ let star_mesh = meshes.add(Sphere::new(1.5).mesh().ico(3).unwrap());
+ let core_star_mesh = meshes.add(Sphere::new(3.0).mesh().ico(4).unwrap());
+
+ let faction_materials: Vec> = FACTIONS
+ .iter()
+ .map(|(_, rgb)| {
+ materials.add(StandardMaterial {
+ base_color: Color::srgb(rgb[0], rgb[1], rgb[2]),
+ emissive: LinearRgba::new(rgb[0] * 1.6, rgb[1] * 1.6, rgb[2] * 1.6, 1.0),
+ unlit: true,
+ ..default()
+ })
+ })
+ .collect();
+ let connection_material = materials.add(StandardMaterial {
+ base_color: Color::srgb(0.11, 0.16, 0.25),
+ emissive: LinearRgba::new(0.08, 0.13, 0.22, 1.0),
+ unlit: true,
+ ..default()
+ });
+
+ let connections = build_connections(systems);
+
+ // Parent group so all galaxy contents despawn together.
+ commands
+ .spawn((
+ Transform::default(),
+ GalaxyScene,
+ GalaxyCreationSpawned,
+ ))
+ .with_children(|parent| {
+ // Star systems.
+ for sys in systems {
+ let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0
+ {
+ // Visual differentiation for the 7 Concord core systems.
+ (core_star_mesh.clone(), faction_materials[0].clone())
+ } else {
+ (star_mesh.clone(), faction_materials[sys.faction_index].clone())
+ };
+ parent.spawn((
+ Mesh3d(mesh),
+ MeshMaterial3d(material),
+ Transform::from_translation(sys.position),
+ StarSystem {
+ id: sys.id.clone(),
+ name: sys.name.clone(),
+ faction: sys.faction,
+ security: sys.security,
+ },
+ ));
+ }
+
+ // Connections rendered as thin cylinders between linked systems.
+ for (a, b) in connections {
+ let from = systems[a].position;
+ let to = systems[b].position;
+ let delta = to - from;
+ let length = delta.length();
+ if length < 0.01 {
+ continue;
+ }
+ let midpoint = (from + to) * 0.5;
+ let direction = delta / length;
+ // Cylinder default axis is +Y; rotate so +Y aligns with the link direction.
+ let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
+ parent.spawn((
+ Mesh3d(meshes.add(Cylinder::new(0.15, length).mesh())),
+ MeshMaterial3d(connection_material.clone()),
+ Transform::from_translation(midpoint).with_rotation(rotation),
+ ));
+ }
+ });
+}
+
+fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
+ let mut connections: Vec<(usize, usize)> = Vec::new();
+ for (i, system) in systems.iter().enumerate() {
+ let mut distances: Vec<(usize, f32)> = systems
+ .iter()
+ .enumerate()
+ .filter(|(j, _)| *j != i)
+ .map(|(j, other)| (j, system.position.distance(other.position)))
+ .collect();
+ distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+ for &(j, _) in distances.iter().take(NEAREST_NEIGHBOR_CONNECTIONS) {
+ let key = if i < j { (i, j) } else { (j, i) };
+ if !connections.contains(&key) {
+ connections.push(key);
+ }
+ }
+ }
+ connections
+}
+
+// ── Lifecycle systems ───────────────────────────────────────────────────────
+
+fn despawn_galaxy_creation(
+ mut commands: Commands,
+ query: Query>,
+) {
+ for entity in &query {
+ // Bevy 0.16: despawn() is recursive by default.
+ commands.entity(entity).despawn();
+ }
+}
+
+fn reset_selection(mut selected: ResMut) {
+ selected.0 = None;
+}
+
+fn escape_to_main_menu(
+ keys: Res>,
+ mut next_state: ResMut>,
+) {
+ if keys.just_pressed(KeyCode::Escape) {
+ next_state.set(AppState::MainMenu);
+ }
+}
+
+// ── Regeneration ────────────────────────────────────────────────────────────
+
+/// Detects [`GalaxyParams`] generation changes and rebuilds the 3D scene.
+///
+/// On first run, the OnEnter handler has already spawned the initial scene —
+/// we just record the generation and bail. On subsequent changes we despawn
+/// every `GalaxyScene` root (recursive by default — kills all star/connection
+/// children) and respawn a fresh galaxy.
+fn regenerate_galaxy_on_param_change(
+ mut commands: Commands,
+ mut meshes: ResMut>,
+ mut materials: ResMut>,
+ params: Res,
+ scene_roots: Query>,
+ mut selected: ResMut,
+ mut last_seen: Local