diff --git a/game_core/src/assets/loader.rs b/game_core/src/assets/loader.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4d26426c8c46efc3e52eb6211f3fe49c9f61d5fc
--- /dev/null
+++ b/game_core/src/assets/loader.rs
@@ -0,0 +1,102 @@
+use std::marker::PhantomData;
+
+use bevy::asset::LoadState;
+use bevy::ecs::system::SystemParam;
+use bevy::prelude::*;
+use bevy::reflect::{TypePath, TypeUuid};
+use micro_banimate::definitions::AnimationSet;
+use micro_ldtk::LdtkProject;
+use micro_musicbox::prelude::AudioSource;
+
+use crate::assets::{AssetHandles, FixedAssetNameMapping, SpriteSheetConfig};
+
+#[derive(SystemParam)]
+pub struct AssetTypeLoader<'w, 's> {
+	pub handles: ResMut<'w, AssetHandles>,
+	pub asset_server: Res<'w, AssetServer>,
+	pub atlas: ResMut<'w, Assets<TextureAtlas>>,
+	#[system_param(ignore)]
+	marker: PhantomData<&'s usize>,
+}
+
+macro_rules! load_basic_type {
+	($name: tt, $type: ty => $key: ident) => {
+		pub fn $name(&mut self, assets: &[FixedAssetNameMapping]) -> Vec<Handle<$type>> {
+			self.load_list(assets, |loader, path, key| {
+				let handle: Handle<$type> = loader.asset_server.load(&path);
+				loader.handles.$key.insert(key, handle.clone());
+				handle
+			})
+		}
+	};
+}
+
+macro_rules! load_state {
+	($container: expr => $key: ident) => {
+		$container
+			.asset_server
+			.get_group_load_state($container.handles.$key.values().map(|f| f.id))
+	};
+}
+
+impl<'w, 's> AssetTypeLoader<'w, 's> {
+	fn load_list<
+		T: Sync + Send + TypeUuid + TypePath + 'static,
+		Loader: Fn(&mut AssetTypeLoader, String, String) -> Handle<T>,
+	>(
+		&mut self,
+		files: &[FixedAssetNameMapping],
+		load: Loader,
+	) -> Vec<Handle<T>> {
+		files
+			.iter()
+			.map(|(path, key)| load(self, path.to_string(), key.to_string()))
+			.collect()
+	}
+
+	load_basic_type!(load_images, Image => images);
+	load_basic_type!(load_audio, AudioSource => sounds);
+	load_basic_type!(load_font, Font => fonts);
+	load_basic_type!(load_animation, AnimationSet => animations);
+	load_basic_type!(load_ldtk, LdtkProject => ldtk_projects);
+
+	pub fn load_spritesheet(
+		&mut self,
+		config: &SpriteSheetConfig,
+		assets: &[FixedAssetNameMapping],
+	) -> Vec<Handle<Image>> {
+		self.load_list(assets, |loader, path, key| {
+			let handle: Handle<Image> = loader.asset_server.load(&path);
+
+			loader
+				.handles
+				.images
+				.insert(key.to_string(), handle.clone());
+
+			let atlas = TextureAtlas::from_grid(
+				handle.clone(),
+				Vec2::new(config.tile_width as f32, config.tile_height as f32),
+				config.columns,
+				config.rows,
+				None,
+				None,
+			);
+
+			let atlas_handle = loader.atlas.add(atlas);
+			loader.handles.atlas.insert(key, atlas_handle);
+
+			handle
+		})
+	}
+
+	pub fn get_all_load_state(&self) -> Vec<LoadState> {
+		let image_state = self
+			.asset_server
+			.get_group_load_state(self.handles.images.values().map(|f| f.id()));
+		let atlas_state = self
+			.asset_server
+			.get_group_load_state(self.handles.images.values().map(|f| f.id()));
+
+		vec![image_state, atlas_state]
+	}
+}
diff --git a/game_core/src/assets/mod.rs b/game_core/src/assets/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..bfdb0730bbe18586b728b8904b8ba703de37205d
--- /dev/null
+++ b/game_core/src/assets/mod.rs
@@ -0,0 +1,28 @@
+mod loader;
+mod resources;
+mod startup;
+
+mod _plugin {
+	use crate::assets::startup::{
+		check_load_resources, start_load_resources, start_preload_resources,
+	};
+	use crate::system::{run_in_setup, AppState};
+	use bevy::prelude::*;
+
+	pub struct AssetLoadingPlugin;
+	impl Plugin for AssetLoadingPlugin {
+		fn build(&self, app: &mut App) {
+			app.init_resource::<super::resources::AssetHandles>()
+				.add_systems(
+					OnEnter(AppState::Preload),
+					(start_preload_resources, start_load_resources),
+				)
+				.add_systems(Update, check_load_resources.run_if(run_in_setup));
+		}
+	}
+}
+
+pub use _plugin::AssetLoadingPlugin;
+pub(self) use loader::AssetTypeLoader;
+pub use resources::AssetHandles;
+pub(self) use resources::{AssetNameMapping, FixedAssetNameMapping, SpriteSheetConfig};
diff --git a/game_core/src/assets/resources.rs b/game_core/src/assets/resources.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b2cb80874fe022caabd0a9a6558e49810fd47fab
--- /dev/null
+++ b/game_core/src/assets/resources.rs
@@ -0,0 +1,92 @@
+use bevy::prelude::*;
+use bevy::utils::HashMap;
+use micro_banimate::definitions::AnimationSet;
+use micro_ldtk::LdtkProject;
+use micro_musicbox::prelude::AudioSource;
+use micro_musicbox::utilities::{SuppliesAudio, TrackType};
+
+#[derive(Copy, Clone, Debug)]
+pub struct SpriteSheetConfig {
+	pub tile_width: usize,
+	pub tile_height: usize,
+	pub columns: usize,
+	pub rows: usize,
+}
+
+impl SpriteSheetConfig {
+	pub fn squares(tile_wh: usize, columns: usize, rows: usize) -> Self {
+		Self {
+			tile_width: tile_wh,
+			tile_height: tile_wh,
+			columns,
+			rows,
+		}
+	}
+
+	pub fn rectangles(tile_width: usize, tile_height: usize, columns: usize, rows: usize) -> Self {
+		Self {
+			tile_width,
+			tile_height,
+			columns,
+			rows,
+		}
+	}
+}
+
+#[derive(Default, Resource)]
+pub struct AssetHandles {
+	pub images: HashMap<String, Handle<Image>>,
+	pub atlas: HashMap<String, Handle<TextureAtlas>>,
+	pub sounds: HashMap<String, Handle<AudioSource>>,
+	pub fonts: HashMap<String, Handle<Font>>,
+	pub animations: HashMap<String, Handle<AnimationSet>>,
+	pub ldtk_projects: HashMap<String, Handle<LdtkProject>>,
+}
+
+macro_rules! fetch_wrapper {
+	($name: tt, $type: ty => $key: ident) => {
+		pub fn $name<T: ToString>(&self, name: T) -> Handle<$type> {
+			let key = name.to_string();
+			match self.$key.get(&key) {
+				Some(handle) => handle.clone_weak(),
+				None => {
+					let keys = self.$key.keys();
+					panic!(
+						"\n\nTried to fetch {} asset with a missing key: {}.\nPossible keys: {}\n\n",
+						stringify!($name),
+						name.to_string(),
+						keys.map(|k| format!("'{}'", k))
+							.collect::<Vec<String>>()
+							.join(", ")
+					)
+				}
+			}
+		}
+	};
+}
+
+impl AssetHandles {
+	fetch_wrapper!(image, Image => images);
+	fetch_wrapper!(atlas, TextureAtlas => atlas);
+	fetch_wrapper!(sound, AudioSource => sounds);
+	fetch_wrapper!(font, Font => fonts);
+	fetch_wrapper!(animation, AnimationSet => animations);
+	fetch_wrapper!(ldtk, LdtkProject => ldtk_projects);
+}
+
+impl SuppliesAudio for AssetHandles {
+	fn resolve_track_name<T: ToString>(&self, name: T) -> TrackType<String> {
+		if self.sounds.contains_key(&name.to_string()) {
+			TrackType::Single(name.to_string())
+		} else {
+			TrackType::Missing
+		}
+	}
+
+	fn get_audio_track<T: ToString>(&self, name: T) -> Option<Handle<AudioSource>> {
+		self.sounds.get(&name.to_string()).map(Handle::clone_weak)
+	}
+}
+
+pub type AssetNameMapping = (String, String);
+pub type FixedAssetNameMapping = (&'static str, &'static str);
diff --git a/game_core/src/assets/startup.rs b/game_core/src/assets/startup.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ef4c6df9ae0bbb63b1d56e0a080aa287a3df5956
--- /dev/null
+++ b/game_core/src/assets/startup.rs
@@ -0,0 +1,112 @@
+use bevy::asset::LoadState;
+use bevy::prelude::*;
+
+use crate::assets::{AssetTypeLoader, SpriteSheetConfig};
+use crate::system::AppState;
+
+pub fn start_preload_resources(
+	mut _commands: Commands,
+	mut next_state: ResMut<NextState<AppState>>,
+) {
+	// TODO: Add preload commands here
+	next_state.set(AppState::Setup);
+}
+
+pub fn start_load_resources(mut loader: AssetTypeLoader) {
+	loader.load_images(&[
+		("splash.png", "splash"),
+		("cresthollow.png", "menu_bg"),
+		("interface/basic_frame.png", "basic_frame"),
+		("interface/banner.png", "banner"),
+		("interface/button_pressed.png", "button_down"),
+		("interface/button_raised.png", "button_up"),
+		("interface/button_hover.png", "button_over"),
+		("interface/vertical_bar.png", "resource_bar"),
+		("interface/vertical_bar_bg.png", "resource_bar_bg"),
+		("interface/vertical_bar_fg.png", "resource_bar_fg"),
+		("interface/health_bar.png", "bar_red"),
+		("interface/mana_bar.png", "bar_blue"),
+		("interface/stamina_bar.png", "bar_yellow"),
+		("interface/energy_bar.png", "bar_green"),
+	]);
+
+	loader.load_audio(&[
+		("splash_sting.ogg", "splash_sting"),
+		("music/menu.ogg", "menu_track"),
+		("effects/running_001.ogg", "running_001"),
+		("effects/fire_crackle_001.ogg", "fire_crackle_001"),
+		("effects/cave_ambience.ogg", "cave_ambience"),
+		("effects/ui_confirm.ogg", "ui_confirm"),
+		("effects/ui_ping.ogg", "ui_ping"),
+	]);
+
+	loader.load_font(&[
+		("fonts/royal.ttf", "royal"),
+		("fonts/equipe.ttf", "equipe"),
+		("fonts/cursed.ttf", "cursed"),
+		("fonts/cursed_bold.ttf", "cursed_bold"),
+	]);
+
+	loader.load_spritesheet(
+		&SpriteSheetConfig::squares(16, 32, 64),
+		&[
+			("sheets/items.png", "items"),
+			("sheets/fx.png", "fx"),
+			("sheets/or_interface.png", "interface"),
+			("sheets/projectiles.png", "projectiles"),
+		],
+	);
+
+	loader.load_spritesheet(
+		&SpriteSheetConfig::squares(32, 16, 32),
+		&[
+			("sheets/creatures/town_guard_human.png", "town_guard_human"),
+			("sheets/creatures/town_guard_orc.png", "town_guard_orc"),
+			("sheets/creatures/town_guard_dwarf.png", "town_guard_dwarf"),
+			("sheets/creatures/town_guard_elf.png", "town_guard_elf"),
+		],
+	);
+
+	loader.load_spritesheet(
+		&SpriteSheetConfig::squares(32, 32, 32),
+		&[
+			("sheets/creatures/human_base.png", "human_base"),
+			("sheets/creatures/goblin_base.png", "goblin_base"),
+			("sheets/creatures/skeleton.png", "skeleton"),
+			("sheets/creatures/flame_acolyte.png", "flame_acolyte"),
+			("sheets/creatures/divine_warrior.png", "divine_warrior"),
+		],
+	);
+
+	loader.load_spritesheet(
+		&SpriteSheetConfig::squares(16, 64, 64),
+		&[
+			("sheets/world_dawn.png", "world"),
+			("sheets/characters.png", "characters"),
+		],
+	);
+
+	loader.load_animation(&[
+		("animations/humanoid.anim.json", "humanoid"),
+		("animations/skeleton.anim.json", "skeleton"),
+		("animations/flame_acolyte.anim.json", "flame_acolyte"),
+		("animations/guards.anim.json", "town_guards"),
+	]);
+
+	loader.load_ldtk(&[
+		("ldtk/dawnish.ldtk", "tile_data"),
+		("ldtk/cresthollow_world.ldtk", "cresthollow_world"),
+	]);
+}
+
+pub fn check_load_resources(
+	mut commands: Commands,
+	loader: AssetTypeLoader,
+	mut next_state: ResMut<NextState<AppState>>,
+) {
+	let load_states = loader.get_all_load_state();
+	if load_states.iter().all(|state| *state == LoadState::Loaded) {
+		info!("Assets loaded successfully");
+		next_state.set(AppState::Splash);
+	}
+}