Build a Simple World

Create a running 3D scene from scratch using the Aether ECS, physics, and renderer.

In this tutorial you will build a complete Aether world from an empty Rust project. By the end you will have entities with position, velocity, and mesh components, a physics simulation, and a software renderer drawing the scene.

Prerequisites

  • Rust stable toolchain installed (rustup.rs)
  • Aether source cloned and built (cargo build from the workspace root)

Create the Project

  1. Create a new binary crate inside the Aether workspace:
cargo new --bin my-first-world
  1. Add the required Aether crates to my-first-world/Cargo.toml:
[package]
name = "my-first-world"
version = "0.1.0"
edition = "2021"

[dependencies]
aether-ecs = { path = "../crates/aether-ecs" }
aether-physics = { path = "../crates/aether-physics" }
aether-renderer = { path = "../crates/aether-renderer" }
  1. Register the crate in the workspace Cargo.toml:
[workspace]
members = [
    # ... existing members
    "my-first-world",
]

Initialize the World and Define Components

  1. Open my-first-world/src/main.rs. The ECS World is the top-level container for entities, components, and systems. Components are plain data structs that must be 'static + Send + Sync:
use aether_ecs::{World, Entity};

#[derive(Clone, Debug)]
struct Position { x: f32, y: f32, z: f32 }

#[derive(Clone, Debug)]
struct Velocity { x: f32, y: f32, z: f32 }

#[derive(Clone, Debug)]
struct Mesh { vertex_count: u32, name: String }

Spawn Entities

  1. In main, create the world and spawn a ground plane and a falling cube:
fn main() {
    let mut world = World::new();

    let ground = world.spawn();
    world.add_component(ground, Position { x: 0.0, y: 0.0, z: 0.0 });
    world.add_component(ground, Mesh { vertex_count: 4, name: "ground_plane".into() });

    let cube = world.spawn();
    world.add_component(cube, Position { x: 0.0, y: 10.0, z: 0.0 });
    world.add_component(cube, Velocity { x: 0.0, y: 0.0, z: 0.0 });
    world.add_component(cube, Mesh { vertex_count: 24, name: "cube".into() });

    let sphere = world.spawn();
    world.add_component(sphere, Position { x: 5.0, y: 8.0, z: 0.0 });
    world.add_component(sphere, Velocity { x: -1.0, y: 0.0, z: 0.0 });
    world.add_component(sphere, Mesh { vertex_count: 128, name: "sphere".into() });
}

Each world.spawn() allocates a generational entity ID. Components are stored in archetype-based column storage for cache-efficient iteration.

Add Physics Systems

Systems are functions that query the world for entities matching a component signature.

  1. Write gravity, movement, and ground-collision systems:
const GRAVITY: f32 = -9.81;
const FIXED_DT: f32 = 1.0 / 60.0;

fn gravity_system(world: &mut World) {
    for (_entity, velocity) in world.query_mut::<&mut Velocity>() {
        velocity.y += GRAVITY * FIXED_DT;
    }
}

fn movement_system(world: &mut World) {
    for (_entity, (pos, vel)) in world.query_mut::<(&mut Position, &Velocity)>() {
        pos.x += vel.x * FIXED_DT;
        pos.y += vel.y * FIXED_DT;
        pos.z += vel.z * FIXED_DT;
    }
}

fn ground_collision_system(world: &mut World) {
    for (_entity, (pos, vel)) in world.query_mut::<(&mut Position, &mut Velocity)>() {
        if pos.y < 0.0 { pos.y = 0.0; vel.y = 0.0; }
    }
}

Integrate Rapier3D Physics

For production, Aether wraps Rapier3D. Replace the manual systems with the engine integration.

  1. Configure the physics world and register rigid bodies:
use aether_physics::{WorldPhysicsConfig, PhysicsWorld, RigidBodyType};

fn setup_physics() -> PhysicsWorld {
    let config = WorldPhysicsConfig {
        gravity: [0.0, -9.81, 0.0],
        time_step: 1.0 / 60.0,
        max_velocity: 100.0,
        enable_ccd: false,
        solver_iterations: 4,
    };
    PhysicsWorld::new(config)
}
  1. Each tick, step the physics world and write results back to ECS:
fn physics_step(world: &mut World, physics: &mut PhysicsWorld) {
    physics.sync_from_ecs(world);
    physics.step();
    physics.sync_to_ecs(world);
}

Add a Renderer

Aether ships a software rasterizer for prototyping that requires no GPU driver.

  1. Initialize the renderer and define a camera:
use aether_renderer::{SoftwareRenderer, RenderConfig, Camera};

fn setup_renderer() -> SoftwareRenderer {
    SoftwareRenderer::new(RenderConfig { width: 800, height: 600, fov_degrees: 75.0 })
}

fn setup_camera() -> Camera {
    Camera {
        position: [0.0, 5.0, -15.0], look_at: [0.0, 2.0, 0.0],
        up: [0.0, 1.0, 0.0], fov: 75.0, near: 0.1, far: 1000.0,
    }
}
  1. Write a render system that draws every entity with Position and Mesh:
fn render_system(world: &World, renderer: &mut SoftwareRenderer, camera: &Camera) {
    renderer.begin_frame(camera);
    for (_entity, (pos, mesh)) in world.query::<(&Position, &Mesh)>() {
        renderer.draw_mesh(&mesh.name, [pos.x, pos.y, pos.z]);
    }
    renderer.end_frame();
}

Run the World Loop

  1. Tie everything together with a fixed-timestep loop:
use std::time::{Duration, Instant};

const TICK_RATE: u32 = 60;
const TICK_DURATION: Duration = Duration::from_nanos(1_000_000_000 / TICK_RATE as u64);
const MAX_TICKS: u32 = 300;

fn main() {
    let mut world = World::new();
    // ... spawn entities from step 5 ...

    let mut physics = setup_physics();
    let mut renderer = setup_renderer();
    let camera = setup_camera();
    let mut tick: u32 = 0;

    loop {
        let frame_start = Instant::now();
        gravity_system(&mut world);
        movement_system(&mut world);
        ground_collision_system(&mut world);
        render_system(&world, &mut renderer, &camera);

        tick += 1;
        if tick >= MAX_TICKS { break; }
        let elapsed = Instant::now() - frame_start;
        if elapsed < TICK_DURATION { std::thread::sleep(TICK_DURATION - elapsed); }
    }
    println!("Simulation complete after {} ticks", tick);
}
  1. Run the project:
cargo run -p my-first-world

What Happens at Runtime

Each tick the engine performs these steps in order:

  1. Gravity -- Every entity with Velocity accelerates downward.
  2. Movement -- Position integrates by velocity * dt.
  3. Ground collision -- Entities below y = 0 are clamped.
  4. Render -- The software renderer draws all (Position, Mesh) pairs.

The cube starts at y = 10, falls under gravity, and stops at y = 0. The sphere arcs sideways while falling.

Next Steps

  • Replace the software renderer with the GPU backend (aether-renderer wgpu)
  • Add keyboard input via aether-input
  • Add networking (see Add Multiplayer)
  • Build game logic visually (see Visual Scripting 101)