Expand description
§Nightshade
A data-oriented game engine written in Rust.
§Getting Started
§1. Add to Cargo.toml
[dependencies]
nightshade = "0.57"§2. Create Your Game
Programs assemble from plugins: the engine capabilities are default plugins, and the game is one more plugin added in the builder.
use nightshade::prelude::*;
#[derive(Default)]
struct MyGame {
camera: Option<Entity>,
}
struct MyGamePlugin;
impl Plugin for MyGamePlugin {
fn build(&self, app: &mut App) {
app.world.res_mut::<nightshade::platform::window::Window>().title = "My Game".to_string();
app.insert_resource(MyGame::default());
app.add_system(Stage::Startup, initialize);
app.add_systems(Stage::Update, (tick, spawn_on_space));
}
}
fn initialize(game: &mut MyGame, world: &mut World) {
world.res_mut::<crate::render::config::RenderSettings>().atmosphere = Atmosphere::Sky;
spawn_sun(world);
let camera = spawn_camera(world, Vec3::new(5.0, 3.0, 5.0), "Camera".to_string());
world.res_mut::<crate::ecs::camera::resources::ActiveCamera>().0 = Some(camera);
game.camera = Some(camera);
spawn_cube_at(world, Vec3::new(0.0, 0.5, 0.0));
}
// Systems take resources as `Res<T>` / `ResMut<T>` parameters, resolved out
// of the resource map. The game state comes first and a trailing `&mut World`
// stays free for spawning, queries, and anything the parameters don't name.
fn tick(game: &mut MyGame, input: Res<Input>, world: &mut World) {
if input.keyboard.just_pressed(KeyCode::Space) {
spawn_cube_at(world, Vec3::new(0.0, 5.0, 0.0));
}
}
// A system that only needs resources drops `&mut World` entirely.
fn spawn_on_space(mut settings: ResMut<crate::render::config::RenderSettings>, input: Res<Input>) {
settings.bloom_enabled = input.keyboard.is_key_pressed(KeyCode::KeyB);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
App::new()
.add_plugins(DefaultPlugins)
.add_plugin(MyGamePlugin)
.run()
}§3. Loading 3D Models
let model_data = include_bytes!("../assets/character.glb");
let result = import_gltf_from_bytes(model_data).unwrap();
let prefab = &result.prefabs[0];
let entity = spawn_prefab_with_animations(world, prefab, &result.animations, Vec3::zeros());
if let Some(player) = world.get_mut::<crate::ecs::animation::components::AnimationPlayer>(entity) {
player.play(0);
player.looping = true;
}§Architecture
§World
The World contains all game state:
- Entities: Unsigned integer handles (
Entity) identifying objects - Components: Data attached to entities (transforms, meshes, physics, etc.)
- Resources: Global singletons read as
Res<T>/ResMut<T>system parameters (for exampleInput,Window,RenderSettings), or throughworld.res::<T>()inside a&mut Worldsystem
§Component Flags
Entities are created with bitflags specifying their components:
let entity = spawn_entities(world,
LOCAL_TRANSFORM | GLOBAL_TRANSFORM | RENDER_MESH,
1
)[0];| Flag | Description |
|---|---|
LOCAL_TRANSFORM | Position/rotation/scale relative to parent |
GLOBAL_TRANSFORM | World-space transform (computed) |
RENDER_MESH | Visible 3D geometry |
MATERIAL_REF | Material reference |
CAMERA | Camera component |
PARENT | Hierarchy parent link |
ANIMATION_PLAYER | Animation controller |
PARTICLE_EMITTER | GPU particles |
CLOTH | GPU cloth sheet |
DECAL | Projected texture |
§Resources
Systems reach resources as Res<T> / ResMut<T> parameters:
fn example(mut settings: ResMut<crate::render::config::RenderSettings>, input: Res<Input>) {
settings.bloom_enabled = true;
settings.atmosphere = Atmosphere::Sky;
let keyboard = &input.keyboard;
let mouse = &input.mouse;
}The same resources are reachable through &mut World for the ad-hoc access a
parameter list cannot name (one field of a resource, a resource held next to a
query, or a helper deep in a call graph):
let dt = world.res::<crate::ecs::time::Time>().delta_time;
let fps = world.res::<crate::ecs::time::Time>().frames_per_second;
let uptime_ms = world.res::<crate::ecs::time::Time>().uptime_milliseconds;
world.res_mut::<crate::ecs::camera::resources::ActiveCamera>().0 = Some(camera_entity);§Render Graph
Rendering uses a pass-based graph. Override State::configure_render_graph
for bloom, SSAO, or custom effects. See the render module.
§Troubleshooting
Black Screen
- Set
world.res::<crate::ecs::camera::resources::ActiveCamera>().0to a valid camera - Ensure camera can see objects (position/orientation)
- Add lighting:
spawn_sun(world)
No Audio (WASM)
- Browsers require user interaction first
- Trigger audio from click/key handlers
Physics Falls Through
- Floor needs
RigidBodyComponent::new_static()ANDColliderComponent - Check collider dimensions match mesh
Animations Not Playing
- Use
spawn_prefab_with_animations()notspawn_prefab() - Call
player.play(index)to start
Textures Missing
- Textures acquired through materials are released on despawn; keep hand-loaded
textures alive with
texture_cache_protect(world.res_mut::<crate::render::wgpu::texture_cache::TextureCache>(), name) - Names are case-sensitive
Entity Invisible
- Needs
RENDER_MESHflag - Needs valid
MaterialRef - Check transform position
§Features
§Aggregate Features
-
engine(default) - Full engine functionality including asset loading, scene graphs, file dialogs, and more. This is the standard feature for building games. -
runtime- Minimal rendering without asset loading. Use withwgpufor lightweight apps that don’t need gltf/image loading. -
full- Everything inengineplusaudio,physics,gamepad,navmesh. -
wgpu(default) - WebGPU-based rendering with DirectX 12, Metal, Vulkan, and WebGPU.
§Granular Features
These allow fine-grained control over dependencies:
-
core- Foundation: ECS (nightshade_ecs), math (nalgebra), windowing (winit), time, petgraph. -
assets- Asset loading: gltf, image, half, bincode, serde_json. Requirescore. -
scene_graph- Scene hierarchy system. Requiresassets. -
file_dialog- Native file dialogs using rfd. Requirescore. -
screenshot- PNG screenshot saving using image. Standalone feature.
§Optional Features
-
egui- Opt-in immediate-mode UI. AddEguiPlugin, then draw from any system withegui_context(world). Composites over the scene and retained UI. -
shell- Developer console with command registration, rendered via the retained UI. -
audio- Audio playback using Kira. AddAudioPluginto open the device and install the audio systems. -
physics- 3D physics using Rapier. -
navmesh- Navigation mesh generation using Recast. -
grass- GPU-driven procedural grass rendering. -
terrain- GPU-driven clipmap terrain with procedural height generation. Impliesgrass. -
picking- Ray-based entity picking. Trimesh picking requiresphysics. -
gamepad- Gamepad input using gilrs. AddGamepadPluginto poll devices.
§Profiling Features
-
tracing- Rolling log files andRUST_LOGsupport. -
tracy- Real-time profiling with Tracy. Impliestracing. -
chrome- Chrome tracing output. Impliestracing.
§Feature Quick Reference
§Audio (audio feature)
// Decode and register a sound by name
let data = load_sound_from_bytes(include_bytes!("sound.ogg")).unwrap();
audio_engine_load_sound(world.plugin_resource_mut::<AudioEngine>(), "shot", data);
// 3D spatial audio: play it through an AudioSource component
let source = spawn_entities(world, LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
world.set(source, AudioSource::new("shot").with_spatial(true).playing());
world.set(source, LocalTransform::from_translation(Vec3::new(5.0, 0.0, 0.0)));§Physics (physics feature)
// Dynamic rigid body with collider. The physics components live in the
// physics plugin's own member world, so they attach through `world.set`
// rather than a core spawn mask.
let entity = spawn_entities(world, LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
world.set(entity, RigidBodyComponent::new_dynamic());
world.set(entity, ColliderComponent::cuboid(1.0, 1.0, 1.0));
world.set(entity, LocalTransform::from_translation(Vec3::new(0.0, 5.0, 0.0)));
// Static floor
let floor = spawn_entities(world, LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0];
world.set(floor, RigidBodyComponent::new_static());
world.set(floor, ColliderComponent::cuboid(10.0, 0.1, 10.0));
// Step physics each frame
fn run_systems(&mut self, world: &mut World) {
let delta_time = world.res::<crate::ecs::time::Time>().delta_time;
physics_world_step(world.plugin_resource_mut::<PhysicsWorld>(), delta_time);
sync_physics_transforms(world);
}§Navigation Mesh (navmesh feature)
// Generate navmesh from world geometry
let config = RecastNavMeshConfig::default();
generate_navmesh_recast(world, &config);
// Spawn an agent
let agent = spawn_navmesh_agent(world, Vec3::new(0.0, 0.0, 0.0), 0.5, 2.0);
// Set destination
set_agent_destination(world, agent, Vec3::new(10.0, 0.0, 10.0));
set_agent_speed(world, agent, 3.5);
// Update each frame
fn run_systems(&mut self, world: &mut World) {
run_navmesh_systems(world, world.res::<crate::ecs::time::Time>().delta_time);
}§Entity Picking (picking feature)
// Bounding-box picking under the cursor
let mouse_pos = world.res::<crate::platform::input::resources::Input>().mouse.position;
if let Some(hit) = pick_closest_entity(world, mouse_pos) {
println!("Hit {:?} at distance {}", hit.entity, hit.distance);
}
// GPU picking (pixel-perfect)
world.res::<crate::ecs::gpu_picking::GpuPicking>().request_pick(mouse_pos.x as u32, mouse_pos.y as u32);
// Next frame:
if let Some(result) = world.res::<crate::ecs::gpu_picking::GpuPicking>().take_result() {
let world_pos = result.world_position;
let normal = result.world_normal;
}§Developer Console (shell feature)
use nightshade::shell::{Command, ShellState};
fn spawn_cubes(args: &[&str], world: &mut World, _ctx: &mut ()) -> String {
let count: usize = args.first().and_then(|s| s.parse().ok()).unwrap_or(1);
for _ in 0..count {
spawn_cube_at(world, Vec3::new(rand::random(), rand::random(), rand::random()));
}
format!("Spawned {} cubes", count)
}
fn initialize(shell: &mut ShellState<()>) {
shell.register_builtin_commands();
shell.registry.register(Command {
name: "spawn",
description: "Spawn random cubes",
usage: "spawn [count]",
execute: spawn_cubes,
});
}
// Press Alt+C to open console, type "spawn 10"§Immediate Mode UI (egui feature)
Opt in with the egui feature and EguiPlugin (or set world.plugin_resource_mut::<EguiState>().enabled). The pass opens
before the app stages each frame, so draw from any system by pulling the frame’s context
with egui_context. The tessellated output composites over the scene and the retained UI.
impl Plugin for DebugUiPlugin {
fn build(&self, app: &mut App) {
app.world.plugin_resource_mut::<EguiState>().enabled = true;
app.add_system(Stage::Update, |world: &mut World| {
if let Some(ctx) = egui_context(world) {
egui::Window::new("Debug").show(&ctx, |ui| {
ui.label(format!("FPS: {:.0}", world.res::<crate::ecs::time::Time>().frames_per_second));
if ui.button("Spawn Cube").clicked() {
spawn_cube_at(world, Vec3::zeros());
}
});
}
});
}
}§Gamepad Input (gamepad feature)
Add GamepadPlugin so the engine polls pads at the top of every frame.
fn tick(world: &mut World) {
let gamepad = world.res::<Gamepad>();
{
// Analog sticks
let move_x = gamepad.left_stick.x;
let move_y = gamepad.left_stick.y;
let look_x = gamepad.right_stick.x;
let look_y = gamepad.right_stick.y;
// Triggers (0.0 - 1.0)
let accelerate = gamepad.right_trigger;
let brake = gamepad.left_trigger;
// Buttons
if gamepad.pressed(GamepadButton::South) { /* A/Cross */ }
if gamepad.just_pressed(GamepadButton::North) { /* Y/Triangle */ }
}
}§Debug Lines
// Create debug lines entity
let debug = spawn_entities(world, LINES, 1)[0];
fn tick(game: &mut MyGame, world: &mut World) {
if let Some(lines) = world.get_mut::<crate::ecs::lines::components::Lines>(game.debug) {
lines.clear();
// Draw a line
lines.push(Line {
start: Vec3::zeros(),
end: Vec3::new(1.0, 1.0, 1.0),
color: Vec4::new(1.0, 0.0, 0.0, 1.0),
});
// Draw coordinate axes
lines.push(Line { start: Vec3::zeros(), end: Vec3::x(), color: Vec4::new(1.0, 0.0, 0.0, 1.0) });
lines.push(Line { start: Vec3::zeros(), end: Vec3::y(), color: Vec4::new(0.0, 1.0, 0.0, 1.0) });
lines.push(Line { start: Vec3::zeros(), end: Vec3::z(), color: Vec4::new(0.0, 0.0, 1.0, 1.0) });
}
}§Particles
let emitter = spawn_entities(world,
PARTICLE_EMITTER | LOCAL_TRANSFORM | GLOBAL_TRANSFORM,
1
)[0];
world.set(emitter, ParticleEmitter {
spawn_rate: 100.0,
particle_lifetime_min: 1.5,
particle_lifetime_max: 2.0,
size_start: 0.2,
size_end: 0.0,
color_gradient: ColorGradient::fire(),
shape: EmitterShape::Sphere { radius: 0.5 },
..Default::default()
});
world.set(emitter, LocalTransform::from_translation(Vec3::new(0.0, 0.0, 0.0)));
// Runs automatically via frame schedule§Decals
let decal = spawn_entities(world,
DECAL | LOCAL_TRANSFORM | GLOBAL_TRANSFORM,
1
)[0];
world.set(decal, Decal {
texture: Some("decal_texture".to_string()),
size: Vec2::new(2.0, 2.0),
depth: 1.0, // projection depth
..Default::default()
});
// Position and orient decal (projects along -Y)
world.set(decal, LocalTransform {
translation: hit_position + hit_normal * 0.01,
rotation: UnitQuaternion::face_towards(&(-hit_normal), &Vec3::x()),
..Default::default()
});§Minimal Example
For a lightweight app without asset loading:
nightshade = { default-features = false, features = ["runtime", "wgpu"] }§Example
use nightshade::prelude::*;
struct GamePlugin;
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.world.res_mut::<nightshade::platform::window::Window>().title = "My Game".to_string();
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
App::new()
.add_plugins(DefaultPlugins)
.add_plugin(GamePlugin)
.run()
}§Frame Lifecycle
Each frame executes in this order:
- Process window/input events
- Update keyboard/mouse/gamepad state
- Calculate delta time
- Run the app’s per-frame stages, in order:
- Game stages
first,update,late_update frame_start: event bus swap, audio (whenAudioPluginis composed)frame_update: simulation (physics, animation, tweens, particles, cloth, vfx)frame_post_update: transform propagation, instanced mesh caches, retained UIrender: the render sync systems (whenRenderPluginis composed)last: input reset, deferred commands, cleanup
- Game stages
- Execute render graph passes
The engine’s per-frame systems live in the same
Stages as game logic: they are registered by
App::new and the capability plugins, after the game
stages, so they run every frame through the one scheduler. Within a stage,
systems run in registration order: the core systems land first, then each
plugin’s in composition order. Register your own with
App::add_system.
§Threading Model
The engine runs on a single thread with async GPU operations via wgpu and fixed timestep physics with interpolation for smooth rendering.
§Platform Notes
- Native (Windows/macOS/Linux): Full feature support with DirectX 12, Metal, or Vulkan.
- WebAssembly: WebGPU backend. Files via drag-and-drop or HTTP. No WASI plugins. Async initialization.
Re-exports§
pub use nightshade_paint as paint;pub use nightshade_platform as platform;pub use nightshade_renderer as render;pub use nightshade_text as text;pub use nightshade_ui as ui;pub use hearsay as networking;pub use crate::ecs::camera::systems::*;pub use crate::ecs::input::systems::*;pub use crate::ecs::transform::systems::*;
Modules§
- app
- The plugin-composed application builder.
- assets
- Assets: what a program loads from disk and what it writes back out.
- default_
plugins - The standard plugin bundle composed above the
Appcore and the capability plugins it names. - ecs
- Entity Component System module.
- egui
- Immediate-mode egui overlay integration, enabled by the
eguifeature. - filesystem
- plugins
- The engine’s capability plugins, each a self-contained unit: the plugin struct plus the systems, components, and resources its feature owns. They are shaped so any of them could stand alone as a crate, and are built into this crate behind feature flags on purpose: cargo features decide what code exists, composing the plugin is what activates it.
- prelude
- Common re-exports for application code.
- render_
driver - The engine-side render driver: everything that reads or writes the ECS
Worldaround a frame. Before the frame it polls readbacks, drains the loading pipeline, generates text meshes, and composes theRenderInputssnapshot; after the frame it restores the taken state and applies the frame’s outputs. The frame itself never sees theWorld: once the inputs are composed, rendering runs against them alone, so a mid-frame read of a taken resource is unrepresentable rather than a latent bug. - run
- Application entry point and main loop.
- schedule
- shell
- Developer console for runtime debugging and commands.
- state
- Application state trait and render-graph resource handles.
- states
- App-builder sugar over nightshade_ecs’s state machine (the
statefeature). The machinery lives innightshade_ecs::state; this wires it onto theAppbuilder and theStage::Firsttransition step, and adaptscurrent_stateto the engineWorldso call sites name only the state type. - user_
interface - What the interface layer reports to the rest of the engine each frame.
- viewport
- Viewport and camera-tile render state. A leaf resource: it names only the
renderer’s
ViewportRectconfig and engine entities, so domains, plugins, and the render driver can all read it without depending on the driver or pulling the renderer intonightshade-platform.