How to Load Assets in Bevy

Load assets in Bevy by placing files in the assets folder and using the AssetServer to load them by filename.

The blank screen problem

You drop a PNG into your project folder and call a load function. In Python or JavaScript, the image appears instantly. In Bevy, you get a handle back, the sprite spawns, and the screen stays blank for a frame or two. The asset is not missing. It is just loading in the background.

This behavior trips up developers coming from synchronous runtimes. You expect a function named load to block until the file is ready. Bevy refuses to block. The game loop must maintain sixty frames per second. If a two hundred megabyte texture decompresses on the main thread, your game freezes. Bevy solves this by decoupling the request from the result. You ask for the file, you get a tracking ID, and you keep running.

Handles and the postal service

Bevy treats assets like mail. You do not walk to the post office and wait in line until your package arrives. You write down the tracking number, hand it to the system, and keep building your house. The tracking number is a Handle<T>. The postal service is the asset pipeline. When the package arrives, the system drops it in your mailbox. You check the mailbox later, or you set up a notification for when it arrives.

A handle is not the asset itself. It is a lightweight reference that points to a slot in the Assets<T> resource. The resource lives in the app state and holds the actual parsed data. You can pass handles between systems, store them in components, and clone them freely. Cloning a handle does not copy the texture or the sound file. It just increments a reference count inside the pipeline. When the last handle pointing to an asset is dropped, Bevy frees the memory.

Convention aside: the community always calls handle.clone() explicitly when sharing handles across systems. It signals to readers that you are copying a reference, not duplicating megabytes of vertex data.

The minimal setup

You need the asset pipeline running before you request files. The DefaultPlugins set includes the AssetPlugin automatically. It configures the background thread pool, sets up the file watcher, and registers default loaders for common formats like PNG, GLB, and WAV.

use bevy::prelude::*;

fn main() {
    // DefaultPlugins registers the AssetPlugin and starts the background loader
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, load_assets)
        .run();
}

fn load_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Queue the file for background loading. Returns immediately.
    let texture_handle = asset_server.load("texture.png");
    
    // Spawn the entity with the handle. Rendering waits for the data.
    commands.spawn(SpriteBundle {
        texture: texture_handle,
        ..default()
    });
}

Place texture.png inside an assets folder at the root of your project. Bevy looks there by default. The load call returns before the file is parsed. The SpriteBundle stores the handle. The render system checks the handle every frame. If the data is missing, it skips drawing. Once the background thread finishes, the sprite appears on the next render pass.

Do not fight the async nature of the pipeline. Trust the handle. It will resolve itself.

What happens behind the scenes

When asset_server.load runs, it pushes a job onto a queue. A dedicated thread picks up the job, reads the file from disk, and runs it through a parser. The parser converts raw bytes into a Bevy type. For images, this means decoding the PNG, converting pixel formats, and uploading the data to the GPU. The result gets stored in the Assets<Image> resource. The original handle now points to valid memory.

You can inspect the pipeline state at any time. The AssetServer tracks pending requests. The Assets<T> resource holds loaded data. Systems that need the actual pixels query Res<Assets<Image>>. They look up the handle, get a reference to the image, and read the dimensions or pixel data.

fn check_loading_progress(
    asset_server: Res<AssetServer>,
    assets: Res<Assets<Image>>,
) {
    // The server knows which handles are still being processed
    let pending = asset_server.get_loaded_asset_ids();
    
    // The resource holds the actual parsed data
    for id in pending {
        if let Some(image) = assets.get(id) {
            // Image is ready. Safe to read dimensions or mipmaps.
            println!("Loaded image with size {:?}", image.size());
        }
    }
}

The separation between request and storage keeps the main thread light. Systems that depend on assets do not stall the game loop. They either run with partial data, skip a frame, or wait for an event. This architecture scales from a single sprite to a hundred concurrent model loads.

Keep your systems focused on one responsibility. Let the pipeline handle the I/O.

A realistic loading workflow

Games rarely load one file and stop. You need to track multiple assets, react to completion, and handle missing files gracefully. Bevy provides an event system for exactly this. AssetEvent::Loaded fires when a file finishes parsing. AssetEvent::Modified fires during hot reloading. AssetEvent::Removed fires when the last handle drops.

fn on_asset_loaded(mut events: EventReader<AssetEvent<Image>>) {
    for event in events.read() {
        match event {
            AssetEvent::Loaded { id, path } => {
                // The file finished parsing. Gameplay can now use it.
                println!("Ready to render: {:?}", path);
            }
            AssetEvent::Modified { id } => {
                // Hot reload detected. The renderer will update automatically.
                println!("Hot reloaded: {:?}", id);
            }
            _ => {}
        }
    }
}

You register this system alongside your startup logic. It runs every frame, but EventReader only yields new events. The overhead is negligible. You can tie gameplay state to these events. Spawn a loading screen entity at startup. Remove it when the critical assets fire Loaded. Queue player movement until the character model arrives.

Convention aside: always filter events by the asset type you care about. EventReader<AssetEvent<Image>> prevents your system from waking up for audio files or fonts. It keeps your event loop clean and predictable.

Path resolution follows a simple rule. Bevy strips the assets/ prefix from your string and looks relative to that directory. asset_server.load("textures/player.png") maps to assets/textures/player.png. Do not include a leading slash. Do not include the assets folder name. The pipeline handles the root mapping automatically.

Treat paths as relative identifiers. The pipeline knows where to look.

Common traps and compiler feedback

New Bevy developers hit the same walls. The first is trying to access the asset immediately after calling load. The handle exists, but the Assets<T> resource does not contain the data yet. Querying it returns None. The solution is to wait for the event or check asset_server.is_loaded(handle).

The second trap is path mismatches. If you pass a string that does not match the file system, Bevy logs a warning and the handle never resolves. The sprite stays invisible. Check your terminal output. Bevy prints the exact path it searched for.

The third trap involves type confusion. Bevy distinguishes between Handle<Image>, Handle<AudioSource>, and Handle<Gltf>. If you pass a texture handle to a component expecting a sound handle, the compiler rejects you with E0308 (mismatched types). The type system catches these errors before they reach the renderer.

fn spawn_player(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Request the model. Returns a Handle<Gltf>
    let model_handle = asset_server.load("models/player.glb");
    
    // Spawn the entity. The GltfMesh component handles extraction.
    commands.spawn((
        Name::new("Player"),
        GltfMesh(model_handle),
        Transform::default(),
    ));
}

The compiler will also complain if you try to mutate an asset directly. Assets<T> only provides shared references. You cannot modify a texture or audio buffer through the resource. Bevy enforces immutability for loaded assets. If you need to change data at runtime, you generate it procedurally and insert it into the resource with assets.add(data).

Read the terminal warnings. They point directly to the missing file or the wrong extension.

Choosing your loading strategy

Use AssetServer::load when you need a background thread to parse and cache a file from disk. Use the Assets<T> resource when you need direct read-only access to already loaded data. Use handle.clone() when you need to share the same asset across multiple systems or entities without duplicating memory. Use AssetEvent listeners when your gameplay logic must wait for a file to finish parsing before proceeding. Reach for assets.add(data) when you are generating textures, meshes, or audio procedurally instead of loading from the file system.

Pick the tool that matches your data flow. The pipeline handles the rest.

Where to go next