diff --git a/Cargo.toml b/Cargo.toml
index f0f3f63..424d03b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -85,7 +85,7 @@ valence_dimension.path = "crates/valence_dimension"
 valence_entity.path = "crates/valence_entity"
 valence_instance.path = "crates/valence_instance"
 valence_inventory.path = "crates/valence_inventory"
-valence_nbt = { path = "crates/valence_nbt" }
+valence_nbt = { path = "crates/valence_nbt", features = ["uuid"] }
 valence_network.path = "crates/valence_network"
 valence_player_list.path = "crates/valence_player_list"
 valence_registry.path = "crates/valence_registry"
diff --git a/crates/valence/benches/idle.rs b/crates/valence/benches/idle.rs
index fd4ac3a..dd9a2da 100644
--- a/crates/valence/benches/idle.rs
+++ b/crates/valence/benches/idle.rs
@@ -21,8 +21,8 @@ pub fn idle_update(c: &mut Criterion) {
 
 fn setup(
     mut commands: Commands,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
     server: Res<Server>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
diff --git a/crates/valence/examples/advancement.rs b/crates/valence/examples/advancement.rs
index e5f6f7a..0801ac7 100644
--- a/crates/valence/examples/advancement.rs
+++ b/crates/valence/examples/advancement.rs
@@ -43,8 +43,8 @@ fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/anvil_loading.rs b/crates/valence/examples/anvil_loading.rs
index c1fd61b..bc97f6f 100644
--- a/crates/valence/examples/anvil_loading.rs
+++ b/crates/valence/examples/anvil_loading.rs
@@ -80,8 +80,8 @@ pub fn main() {
 
 fn setup(
     mut commands: Commands,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
     server: Res<Server>,
 ) {
     let instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
diff --git a/crates/valence/examples/bench_players.rs b/crates/valence/examples/bench_players.rs
index f675bf4..4680574 100644
--- a/crates/valence/examples/bench_players.rs
+++ b/crates/valence/examples/bench_players.rs
@@ -57,8 +57,8 @@ fn print_tick_time(
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/biomes.rs b/crates/valence/examples/biomes.rs
index 1990c0a..d5735e0 100644
--- a/crates/valence/examples/biomes.rs
+++ b/crates/valence/examples/biomes.rs
@@ -16,8 +16,8 @@ pub fn main() {
 
 fn setup(
     mut commands: Commands,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
     biome_reg: Res<BiomeRegistry>,
     server: Res<Server>,
 ) {
diff --git a/crates/valence/examples/block_entities.rs b/crates/valence/examples/block_entities.rs
index d8d3008..36b26d7 100644
--- a/crates/valence/examples/block_entities.rs
+++ b/crates/valence/examples/block_entities.rs
@@ -22,8 +22,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/building.rs b/crates/valence/examples/building.rs
index c9af37e..f4bb404 100644
--- a/crates/valence/examples/building.rs
+++ b/crates/valence/examples/building.rs
@@ -26,8 +26,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/chest.rs b/crates/valence/examples/chest.rs
index 511aa7a..9a4bd48 100644
--- a/crates/valence/examples/chest.rs
+++ b/crates/valence/examples/chest.rs
@@ -21,8 +21,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/combat.rs b/crates/valence/examples/combat.rs
index b554b61..66198f5 100644
--- a/crates/valence/examples/combat.rs
+++ b/crates/valence/examples/combat.rs
@@ -32,8 +32,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/conway.rs b/crates/valence/examples/conway.rs
index e7e31b4..511a329 100644
--- a/crates/valence/examples/conway.rs
+++ b/crates/valence/examples/conway.rs
@@ -24,7 +24,6 @@ pub fn main() {
 
     App::new()
         .add_plugins(DefaultPlugins)
-        .add_startup_system(setup_biomes.before(setup))
         .add_startup_system(setup)
         .add_system(init_clients)
         .add_systems((
@@ -37,19 +36,16 @@ pub fn main() {
         .run();
 }
 
-// TODO: this is a hack.
-fn setup_biomes(mut biomes: Query<&mut Biome>) {
-    for mut biome in &mut biomes {
-        biome.grass_color = Some(0x00ff00);
-    }
-}
-
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: ResMut<DimensionTypeRegistry>,
+    mut biomes: ResMut<BiomeRegistry>,
 ) {
+    for (_, _, biome) in biomes.iter_mut() {
+        biome.effects.grass_color = Some(0x00ff00);
+    }
+
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
     for z in -10..10 {
diff --git a/crates/valence/examples/cow_sphere.rs b/crates/valence/examples/cow_sphere.rs
index 68263c5..8fd696b 100644
--- a/crates/valence/examples/cow_sphere.rs
+++ b/crates/valence/examples/cow_sphere.rs
@@ -34,8 +34,8 @@ fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/death.rs b/crates/valence/examples/death.rs
index 41573a5..3fa5e7e 100644
--- a/crates/valence/examples/death.rs
+++ b/crates/valence/examples/death.rs
@@ -19,8 +19,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     for block in [BlockState::GRASS_BLOCK, BlockState::DEEPSLATE] {
         let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
diff --git a/crates/valence/examples/entity_hitbox.rs b/crates/valence/examples/entity_hitbox.rs
index d867971..8b17eea 100644
--- a/crates/valence/examples/entity_hitbox.rs
+++ b/crates/valence/examples/entity_hitbox.rs
@@ -25,8 +25,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/parkour.rs b/crates/valence/examples/parkour.rs
index 4b72116..e0c5f38 100644
--- a/crates/valence/examples/parkour.rs
+++ b/crates/valence/examples/parkour.rs
@@ -57,8 +57,8 @@ fn init_clients(
         Added<Client>,
     >,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
     mut commands: Commands,
 ) {
     for (entity, mut client, mut loc, mut is_flat, mut game_mode) in clients.iter_mut() {
diff --git a/crates/valence/examples/particles.rs b/crates/valence/examples/particles.rs
index 5871e22..432a19b 100644
--- a/crates/valence/examples/particles.rs
+++ b/crates/valence/examples/particles.rs
@@ -24,8 +24,8 @@ struct ParticleVec(Vec<Particle>);
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/player_list.rs b/crates/valence/examples/player_list.rs
index 761ec01..77ba133 100644
--- a/crates/valence/examples/player_list.rs
+++ b/crates/valence/examples/player_list.rs
@@ -27,8 +27,8 @@ fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/resource_pack.rs b/crates/valence/examples/resource_pack.rs
index 97058a0..ccbac65 100644
--- a/crates/valence/examples/resource_pack.rs
+++ b/crates/valence/examples/resource_pack.rs
@@ -21,8 +21,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/examples/terrain.rs b/crates/valence/examples/terrain.rs
index 2551b40..a4a978c 100644
--- a/crates/valence/examples/terrain.rs
+++ b/crates/valence/examples/terrain.rs
@@ -60,8 +60,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let seconds_per_day = 86_400;
     let seed = (SystemTime::now()
diff --git a/crates/valence/examples/text.rs b/crates/valence/examples/text.rs
index 7e8847e..a369cd7 100644
--- a/crates/valence/examples/text.rs
+++ b/crates/valence/examples/text.rs
@@ -18,8 +18,8 @@ pub fn main() {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    dimensions: Query<&DimensionType>,
-    biomes: Query<&Biome>,
+    dimensions: Res<DimensionTypeRegistry>,
+    biomes: Res<BiomeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);
 
diff --git a/crates/valence/src/tests.rs b/crates/valence/src/tests.rs
index 3a8452a..86e5cbd 100644
--- a/crates/valence/src/tests.rs
+++ b/crates/valence/src/tests.rs
@@ -4,15 +4,16 @@ use std::time::Instant;
 
 use bevy_app::prelude::*;
 use bevy_ecs::prelude::*;
-use bevy_ecs::schedule::{LogLevel, ScheduleBuildSettings};
 use bytes::{Buf, BufMut, BytesMut};
 use uuid::Uuid;
+use valence_biome::BiomeRegistry;
 use valence_client::ClientBundleArgs;
 use valence_core::protocol::decode::{PacketDecoder, PacketFrame};
 use valence_core::protocol::encode::PacketEncoder;
 use valence_core::protocol::var_int::VarInt;
 use valence_core::protocol::{Encode, Packet};
 use valence_core::{ident, CoreSettings, Server};
+use valence_dimension::DimensionTypeRegistry;
 use valence_entity::Location;
 use valence_network::{ConnectionMode, NetworkSettings};
 
@@ -37,9 +38,17 @@ fn scenario_single_client(app: &mut App) -> (Entity, MockClientHelper) {
 
     app.add_plugins(DefaultPlugins);
 
-    let server = app.world.resource::<Server>();
-    let instance = Instance::new_unit_testing(ident!("overworld"), server);
+    app.update(); // Initialize plugins.
+
+    let instance = Instance::new(
+        ident!("overworld"),
+        app.world.resource::<DimensionTypeRegistry>(),
+        app.world.resource::<BiomeRegistry>(),
+        app.world.resource::<Server>(),
+    );
+
     let instance_ent = app.world.spawn(instance).id();
+
     let (client, client_helper) = create_mock_client();
 
     let client_ent = app.world.spawn(client).id();
@@ -47,14 +56,6 @@ fn scenario_single_client(app: &mut App) -> (Entity, MockClientHelper) {
     // Set initial location.
     app.world.get_mut::<Location>(client_ent).unwrap().0 = instance_ent;
 
-    // Print warnings if there are ambiguities in the schedule.
-    app.edit_schedule(CoreSchedule::Main, |schedule| {
-        schedule.set_build_settings(ScheduleBuildSettings {
-            ambiguity_detection: LogLevel::Warn,
-            ..Default::default()
-        });
-    });
-
     (client_ent, client_helper)
 }
 
diff --git a/crates/valence_biome/Cargo.toml b/crates/valence_biome/Cargo.toml
index c985f28..5ecd8fc 100644
--- a/crates/valence_biome/Cargo.toml
+++ b/crates/valence_biome/Cargo.toml
@@ -4,10 +4,11 @@ version.workspace = true
 edition.workspace = true
 
 [dependencies]
+anyhow.workspace = true
 bevy_app.workspace = true
 bevy_ecs.workspace = true
-anyhow.workspace = true
+serde.workspace = true
 tracing.workspace = true
-valence_nbt.workspace = true
-valence_registry.workspace = true
 valence_core.workspace = true
+valence_nbt = { workspace = true, features = ["serde"] }
+valence_registry.workspace = true
diff --git a/crates/valence_biome/src/lib.rs b/crates/valence_biome/src/lib.rs
index 6ce3eea..83bbd6f 100644
--- a/crates/valence_biome/src/lib.rs
+++ b/crates/valence_biome/src/lib.rs
@@ -17,111 +17,43 @@
     clippy::dbg_macro
 )]
 
-use std::ops::Index;
+use std::ops::{Deref, DerefMut};
 
-use anyhow::{bail, Context};
 use bevy_app::prelude::*;
 use bevy_ecs::prelude::*;
+use serde::{Deserialize, Serialize};
 use tracing::error;
 use valence_core::ident;
 use valence_core::ident::Ident;
-use valence_nbt::{compound, Value};
-use valence_registry::{RegistryCodec, RegistryCodecSet, RegistryValue};
+use valence_nbt::serde::CompoundSerializer;
+use valence_registry::codec::{RegistryCodec, RegistryValue};
+use valence_registry::{Registry, RegistryIdx, RegistrySet};
 
 pub struct BiomePlugin;
 
-#[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
-
-struct BiomeSet;
-
 impl Plugin for BiomePlugin {
-    fn build(&self, app: &mut bevy_app::App) {
-        app.insert_resource(BiomeRegistry {
-            id_to_biome: vec![],
-        })
-        .configure_set(
-            BiomeSet
-                .in_base_set(CoreSet::PostUpdate)
-                .before(RegistryCodecSet),
-        )
-        .add_systems(
-            (update_biome_registry, remove_biomes_from_registry)
-                .chain()
-                .in_set(BiomeSet),
-        )
-        .add_startup_system(load_default_biomes.in_base_set(StartupSet::PreStartup));
+    fn build(&self, app: &mut App) {
+        app.init_resource::<BiomeRegistry>()
+            .add_startup_system(load_default_biomes.in_base_set(CoreSet::PreUpdate))
+            .add_system(
+                update_biome_registry
+                    .in_base_set(CoreSet::PostUpdate)
+                    .before(RegistrySet),
+            );
     }
 }
 
-fn load_default_biomes(
-    mut reg: ResMut<BiomeRegistry>,
-    codec: Res<RegistryCodec>,
-    mut commands: Commands,
-) {
-    let mut helper = move || {
+fn load_default_biomes(mut reg: ResMut<BiomeRegistry>, codec: Res<RegistryCodec>) {
+    let mut helper = move || -> anyhow::Result<()> {
         for value in codec.registry(BiomeRegistry::KEY) {
-            let downfall = *value
-                .element
-                .get("downfall")
-                .and_then(|v| v.as_float())
-                .context("invalid downfall")?;
+            let biome = Biome::deserialize(value.element.clone())?;
 
-            let Some(Value::Compound(effects)) = value.element.get("effects") else {
-                bail!("missing biome effects")
-            };
-
-            let fog_color = *effects
-                .get("fog_color")
-                .and_then(|v| v.as_int())
-                .context("invalid fog color")?;
-
-            let sky_color = *effects
-                .get("sky_color")
-                .and_then(|v| v.as_int())
-                .context("invalid sky color")?;
-
-            let water_color = *effects
-                .get("water_color")
-                .and_then(|v| v.as_int())
-                .context("invalid water color")?;
-
-            let water_fog_color = *effects
-                .get("water_fog_color")
-                .and_then(|v| v.as_int())
-                .context("invalid water fog color")?;
-
-            let grass_color = effects.get("grass_color").and_then(|v| v.as_int()).copied();
-
-            let has_precipitation = *value
-                .element
-                .get("has_precipitation")
-                .and_then(|v| v.as_byte())
-                .context("invalid has_precipitation")?
-                != 0;
-
-            let temperature = *value
-                .element
-                .get("temperature")
-                .and_then(|v| v.as_float())
-                .context("invalid temperature")?;
-
-            let entity = commands
-                .spawn(Biome {
-                    name: value.name.clone(),
-                    downfall,
-                    fog_color,
-                    sky_color,
-                    water_color,
-                    water_fog_color,
-                    grass_color,
-                    has_precipitation,
-                    temperature,
-                })
-                .id();
-
-            reg.id_to_biome.push(entity);
+            reg.insert(value.name.clone(), biome);
         }
 
+        // Move "plains" to the front so that `BiomeId::default()` is the ID of plains.
+        reg.swap_to_front(ident!("plains"));
+
         Ok(())
     };
 
@@ -130,126 +62,102 @@ fn load_default_biomes(
     }
 }
 
-/// Add new biomes to or update existing biomes in the registry.
-fn update_biome_registry(
-    mut reg: ResMut<BiomeRegistry>,
-    mut codec: ResMut<RegistryCodec>,
-    biomes: Query<(Entity, &Biome), Changed<Biome>>,
-) {
-    for (entity, biome) in &biomes {
-        let biome_registry = codec.registry_mut(BiomeRegistry::KEY);
+fn update_biome_registry(reg: Res<BiomeRegistry>, mut codec: ResMut<RegistryCodec>) {
+    if reg.is_changed() {
+        let biomes = codec.registry_mut(BiomeRegistry::KEY);
 
-        let mut effects = compound! {
-            "fog_color" => biome.fog_color,
-            "sky_color" => biome.sky_color,
-            "water_color" => biome.water_color,
-            "water_fog_color" => biome.water_fog_color,
-        };
+        biomes.clear();
 
-        if let Some(grass_color) = biome.grass_color {
-            effects.insert("grass_color", grass_color);
-        }
-
-        let biome_compound = compound! {
-            "downfall" => biome.downfall,
-            "effects" => effects,
-            "has_precipitation" => biome.has_precipitation,
-            "temperature" => biome.temperature,
-        };
-
-        if let Some(value) = biome_registry.iter_mut().find(|v| v.name == biome.name) {
-            value.name = biome.name.clone();
-            value.element.merge(biome_compound);
-        } else {
-            biome_registry.push(RegistryValue {
-                name: biome.name.clone(),
-                element: biome_compound,
-            });
-            reg.id_to_biome.push(entity);
-        }
-
-        assert_eq!(
-            biome_registry.len(),
-            reg.id_to_biome.len(),
-            "biome registry and biome lookup table differ in length"
-        );
+        biomes.extend(reg.iter().map(|(_, name, biome)| {
+            RegistryValue {
+                name: name.into(),
+                element: biome
+                    .serialize(CompoundSerializer)
+                    .expect("failed to serialize biome"),
+            }
+        }));
     }
 }
 
-/// Remove deleted biomes from the registry.
-fn remove_biomes_from_registry(
-    mut biomes: RemovedComponents<Biome>,
-    mut reg: ResMut<BiomeRegistry>,
-    mut codec: ResMut<RegistryCodec>,
-) {
-    for biome in biomes.iter() {
-        if let Some(idx) = reg.id_to_biome.iter().position(|entity| *entity == biome) {
-            reg.id_to_biome.remove(idx);
-            codec.registry_mut(BiomeRegistry::KEY).remove(idx);
-        }
-    }
-}
-
-#[derive(Resource)]
+#[derive(Resource, Default, Debug)]
 pub struct BiomeRegistry {
-    id_to_biome: Vec<Entity>,
+    reg: Registry<BiomeId, Biome>,
 }
 
 impl BiomeRegistry {
-    pub const KEY: Ident<&str> = ident!("minecraft:worldgen/biome");
+    pub const KEY: Ident<&str> = ident!("worldgen/biome");
+}
 
-    pub fn get_by_id(&self, id: BiomeId) -> Option<Entity> {
-        self.id_to_biome.get(id.0 as usize).cloned()
-    }
+impl Deref for BiomeRegistry {
+    type Target = Registry<BiomeId, Biome>;
 
-    pub fn iter(&self) -> impl Iterator<Item = (BiomeId, Entity)> + '_ {
-        self.id_to_biome
-            .iter()
-            .enumerate()
-            .map(|(id, biome)| (BiomeId(id as _), *biome))
+    fn deref(&self) -> &Self::Target {
+        &self.reg
     }
 }
 
-impl Index<BiomeId> for BiomeRegistry {
-    type Output = Entity;
-
-    fn index(&self, index: BiomeId) -> &Self::Output {
-        self.id_to_biome
-            .get(index.0 as usize)
-            .unwrap_or_else(|| panic!("invalid {index:?}"))
+impl DerefMut for BiomeRegistry {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.reg
     }
 }
 
-/// An index into the biome registry.
-#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
-pub struct BiomeId(pub u16);
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)]
+pub struct BiomeId(u32);
 
-#[derive(Component, Clone, Debug)]
+impl RegistryIdx for BiomeId {
+    const MAX: usize = u32::MAX as _;
+
+    #[inline]
+    fn to_index(self) -> usize {
+        self.0 as _
+    }
+
+    #[inline]
+    fn from_index(idx: usize) -> Self {
+        Self(idx as _)
+    }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct Biome {
-    pub name: Ident<String>,
     pub downfall: f32,
-    pub fog_color: i32,
-    pub sky_color: i32,
-    pub water_color: i32,
-    pub water_fog_color: i32,
-    pub grass_color: Option<i32>,
+    pub effects: BiomeEffects,
     pub has_precipitation: bool,
     pub temperature: f32,
     // TODO: more stuff.
 }
 
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct BiomeEffects {
+    pub fog_color: u32,
+    pub sky_color: u32,
+    pub water_color: u32,
+    pub water_fog_color: u32,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub grass_color: Option<u32>,
+    // TODO: more stuff.
+}
+
 impl Default for Biome {
     fn default() -> Self {
         Self {
-            name: ident!("plains").into(),
             downfall: 0.4,
-            fog_color: 12638463,
-            sky_color: 7907327,
-            water_color: 4159204,
-            water_fog_color: 329011,
-            grass_color: None,
+            effects: BiomeEffects::default(),
             has_precipitation: true,
             temperature: 0.8,
         }
     }
 }
+
+impl Default for BiomeEffects {
+    fn default() -> Self {
+        Self {
+            fog_color: 12638463,
+            sky_color: 7907327,
+            water_color: 4159204,
+            water_fog_color: 329011,
+            grass_color: None,
+        }
+    }
+}
diff --git a/crates/valence_client/src/lib.rs b/crates/valence_client/src/lib.rs
index aec65a0..caebc3f 100644
--- a/crates/valence_client/src/lib.rs
+++ b/crates/valence_client/src/lib.rs
@@ -67,7 +67,9 @@ use valence_instance::packet::{
     ChunkLoadDistanceS2c, ChunkRenderDistanceCenterS2c, UnloadChunkS2c,
 };
 use valence_instance::{ClearInstanceChangesSet, Instance, WriteUpdatePacketsToInstancesSet};
-use valence_registry::{RegistryCodec, RegistryCodecSet, TagsRegistry};
+use valence_registry::codec::RegistryCodec;
+use valence_registry::tags::TagsRegistry;
+use valence_registry::RegistrySet;
 
 pub mod action;
 pub mod chat;
@@ -112,7 +114,7 @@ impl Plugin for ClientPlugin {
     fn build(&self, app: &mut App) {
         app.add_systems(
             (
-                initial_join.after(RegistryCodecSet),
+                initial_join.after(RegistrySet),
                 update_chunk_load_dist,
                 read_data_in_old_view
                     .after(WriteUpdatePacketsToInstancesSet)
diff --git a/crates/valence_core/src/protocol/c2s.rs b/crates/valence_core/src/protocol/c2s.rs
deleted file mode 100644
index 06b4dff..0000000
--- a/crates/valence_core/src/protocol/c2s.rs
+++ /dev/null
@@ -1,156 +0,0 @@
-/*
-pub use advancement_tab::AdvancementTabC2s;
-pub use boat_paddle::BoatPaddleStateC2s;
-pub use book_update::BookUpdateC2s;
-pub use button_click::ButtonClickC2s;
-pub use chat_message::ChatMessageC2s;
-pub use click_slot::ClickSlotC2s;
-pub use client_command::ClientCommandC2s;
-pub use client_settings::ClientSettingsC2s;
-pub use client_status::ClientStatusC2s;
-pub use close_handled_screen::CloseHandledScreenC2s;
-pub use command_execution::CommandExecutionC2s;
-pub use craft_request::CraftRequestC2s;
-pub use creative_inventory_action::CreativeInventoryActionC2s;
-pub use custom_payload::CustomPayloadC2s;
-pub use hand_swing::HandSwingC2s;
-pub use jigsaw_generating::JigsawGeneratingC2s;
-pub use keep_alive::KeepAliveC2s;
-pub use message_acknowledgment::MessageAcknowledgmentC2s;
-pub use pick_from_inventory::PickFromInventoryC2s;
-pub use play_pong::PlayPongC2s;
-pub use player_action::PlayerActionC2s;
-pub use player_input::PlayerInputC2s;
-pub use player_interact_block::PlayerInteractBlockC2s;
-pub use player_interact_entity::PlayerInteractEntityC2s;
-pub use player_interact_item::PlayerInteractItemC2s;
-pub use player_move::{Full, LookAndOnGround, OnGroundOnly, PositionAndOnGround};
-pub use player_session::PlayerSessionC2s;
-pub use query_block_nbt::QueryBlockNbtC2s;
-pub use query_entity_nbt::QueryEntityNbtC2s;
-pub use recipe_book_data::RecipeBookDataC2s;
-pub use recipe_category_options::RecipeCategoryOptionsC2s;
-pub use rename_item::RenameItemC2s;
-pub use request_command_completions::RequestCommandCompletionsC2s;
-pub use resource_pack_status::ResourcePackStatusC2s;
-pub use select_merchant_trade::SelectMerchantTradeC2s;
-pub use spectator_teleport::SpectatorTeleportC2s;
-pub use teleport_confirm::TeleportConfirmC2s;
-pub use update_beacon::UpdateBeaconC2s;
-pub use update_command_block::UpdateCommandBlockC2s;
-pub use update_command_block_minecart::UpdateCommandBlockMinecartC2s;
-pub use update_difficulty::UpdateDifficultyC2s;
-pub use update_difficulty_lock::UpdateDifficultyLockC2s;
-pub use update_jigsaw::UpdateJigsawC2s;
-pub use update_player_abilities::UpdatePlayerAbilitiesC2s;
-pub use update_selected_slot::UpdateSelectedSlotC2s;
-pub use update_sign::UpdateSignC2s;
-pub use update_structure_block::UpdateStructureBlockC2s;
-pub use vehicle_move::VehicleMoveC2s;
-
-pub mod advancement_tab;
-pub mod boat_paddle;
-pub mod book_update;
-pub mod button_click;
-pub mod chat_message;
-pub mod click_slot;
-pub mod client_command;
-pub mod client_settings;
-pub mod client_status;
-pub mod close_handled_screen;
-pub mod command_execution;
-pub mod craft_request;
-pub mod creative_inventory_action;
-pub mod custom_payload;
-pub mod hand_swing;
-pub mod jigsaw_generating;
-pub mod keep_alive;
-pub mod message_acknowledgment;
-pub mod pick_from_inventory;
-pub mod play_pong;
-pub mod player_action;
-pub mod player_input;
-pub mod player_interact_block;
-pub mod player_interact_entity;
-pub mod player_interact_item;
-pub mod player_move;
-pub mod player_session;
-pub mod query_block_nbt;
-pub mod query_entity_nbt;
-pub mod recipe_book_data;
-pub mod recipe_category_options;
-pub mod rename_item;
-pub mod request_command_completions;
-pub mod resource_pack_status;
-pub mod select_merchant_trade;
-pub mod spectator_teleport;
-pub mod teleport_confirm;
-pub mod update_beacon;
-pub mod update_command_block;
-pub mod update_command_block_minecart;
-pub mod update_difficulty;
-pub mod update_difficulty_lock;
-pub mod update_jigsaw;
-pub mod update_player_abilities;
-pub mod update_selected_slot;
-pub mod update_sign;
-pub mod update_structure_block;
-pub mod vehicle_move;
-
-packet_group! {
-    #[derive(Clone)]
-    C2sPlayPacket<'a> {
-        AdvancementTabC2s<'a>,
-        BoatPaddleStateC2s,
-        BookUpdateC2s<'a>,
-        ButtonClickC2s,
-        ChatMessageC2s<'a>,
-        ClickSlotC2s,
-        ClientCommandC2s,
-        ClientSettingsC2s<'a>,
-        ClientStatusC2s,
-        CloseHandledScreenC2s,
-        CommandExecutionC2s<'a>,
-        CraftRequestC2s<'a>,
-        CreativeInventoryActionC2s,
-        CustomPayloadC2s<'a>,
-        Full,
-        HandSwingC2s,
-        JigsawGeneratingC2s,
-        KeepAliveC2s,
-        LookAndOnGround,
-        MessageAcknowledgmentC2s,
-        OnGroundOnly,
-        PickFromInventoryC2s,
-        PlayerActionC2s,
-        PlayerInputC2s,
-        PlayerInteractBlockC2s,
-        PlayerInteractEntityC2s,
-        PlayerInteractItemC2s,
-        PlayerSessionC2s<'a>,
-        PlayPongC2s,
-        PositionAndOnGround,
-        QueryBlockNbtC2s,
-        QueryEntityNbtC2s,
-        RecipeBookDataC2s<'a>,
-        RecipeCategoryOptionsC2s,
-        RenameItemC2s<'a>,
-        RequestCommandCompletionsC2s<'a>,
-        ResourcePackStatusC2s,
-        SelectMerchantTradeC2s,
-        SpectatorTeleportC2s,
-        TeleportConfirmC2s,
-        UpdateBeaconC2s,
-        UpdateCommandBlockC2s<'a>,
-        UpdateCommandBlockMinecartC2s<'a>,
-        UpdateDifficultyC2s,
-        UpdateDifficultyLockC2s,
-        UpdateJigsawC2s<'a>,
-        UpdatePlayerAbilitiesC2s,
-        UpdateSelectedSlotC2s,
-        UpdateSignC2s<'a>,
-        UpdateStructureBlockC2s<'a>,
-        VehicleMoveC2s,
-    }
-}
-*/
diff --git a/crates/valence_core/src/protocol/s2c.rs b/crates/valence_core/src/protocol/s2c.rs
deleted file mode 100644
index 567bc0c..0000000
--- a/crates/valence_core/src/protocol/s2c.rs
+++ /dev/null
@@ -1,338 +0,0 @@
-/*
-pub use advancement_update::AdvancementUpdateS2c;
-pub use block_breaking_progress::BlockBreakingProgressS2c;
-pub use block_entity_update::BlockEntityUpdateS2c;
-pub use block_event::BlockEventS2c;
-pub use block_update::BlockUpdateS2c;
-pub use boss_bar::BossBarS2c;
-pub use bundle_splitter::BundleSplitter;
-pub use chat_message::ChatMessageS2c;
-pub use chat_suggestions::ChatSuggestionsS2c;
-pub use chunk_biome_data::ChunkBiomeDataS2c;
-pub use chunk_data::ChunkDataS2c;
-pub use chunk_delta_update::ChunkDeltaUpdateS2c;
-pub use chunk_load_distance::ChunkLoadDistanceS2c;
-pub use chunk_render_distance_center::ChunkRenderDistanceCenterS2c;
-pub use clear_title::ClearTitleS2c;
-pub use close_screen::CloseScreenS2c;
-pub use command_suggestions::CommandSuggestionsS2c;
-pub use command_tree::CommandTreeS2c;
-pub use cooldown_update::CooldownUpdateS2c;
-pub use craft_failed_response::CraftFailedResponseS2c;
-pub use custom_payload::CustomPayloadS2c;
-pub use damage_tilt::DamageTiltS2c;
-pub use death_message::DeathMessageS2c;
-pub use difficulty::DifficultyS2c;
-pub use disconnect::DisconnectS2c;
-pub use end_combat::EndCombatS2c;
-pub use enter_combat::EnterCombatS2c;
-pub use entities_destroy::EntitiesDestroyS2c;
-pub use entity_animation::EntityAnimationS2c;
-pub use entity_attach::EntityAttachS2c;
-pub use entity_attributes::EntityAttributesS2c;
-pub use entity_damage::EntityDamageS2c;
-pub use entity_equipment_update::EntityEquipmentUpdateS2c;
-pub use entity_move::{MoveRelative, Rotate, RotateAndMoveRelative};
-pub use entity_passengers_set::EntityPassengersSetS2c;
-pub use entity_position::EntityPositionS2c;
-pub use entity_set_head_yaw::EntitySetHeadYawS2c;
-pub use entity_spawn::EntitySpawnS2c;
-pub use entity_status::EntityStatusS2c;
-pub use entity_status_effect::EntityStatusEffectS2c;
-pub use entity_tracker_update::EntityTrackerUpdateS2c;
-pub use entity_velocity_update::EntityVelocityUpdateS2c;
-pub use experience_bar_update::ExperienceBarUpdateS2c;
-pub use experience_orb_spawn::ExperienceOrbSpawnS2c;
-pub use explosion::ExplosionS2c;
-pub use features::FeaturesS2c;
-pub use game_join::GameJoinS2c;
-pub use game_message::GameMessageS2c;
-pub use game_state_change::GameStateChangeS2c;
-pub use health_update::HealthUpdateS2c;
-pub use inventory::InventoryS2c;
-pub use item_pickup_animation::ItemPickupAnimationS2c;
-pub use keep_alive::KeepAliveS2c;
-pub use light_update::LightUpdateS2c;
-pub use look_at::LookAtS2c;
-pub use map_update::MapUpdateS2c;
-pub use nbt_query_response::NbtQueryResponseS2c;
-pub use open_horse_screen::OpenHorseScreenS2c;
-pub use open_screen::OpenScreenS2c;
-pub use open_written_book::OpenWrittenBookS2c;
-pub use overlay_message::OverlayMessageS2c;
-pub use particle::ParticleS2c;
-pub use play_ping::PlayPingS2c;
-pub use play_sound::PlaySoundS2c;
-pub use play_sound_from_entity::PlaySoundFromEntityS2c;
-pub use player_abilities::PlayerAbilitiesS2c;
-pub use player_action_response::PlayerActionResponseS2c;
-pub use player_list::PlayerListS2c;
-pub use player_list_header::PlayerListHeaderS2c;
-pub use player_position_look::PlayerPositionLookS2c;
-pub use player_remove::PlayerRemoveS2c;
-pub use player_respawn::PlayerRespawnS2c;
-pub use player_spawn::PlayerSpawnS2c;
-pub use player_spawn_position::PlayerSpawnPositionS2c;
-pub use profileless_chat_message::ProfilelessChatMessageS2c;
-pub use remove_entity_status_effect::RemoveEntityStatusEffectS2c;
-pub use remove_message::RemoveMessageS2c;
-pub use resource_pack_send::ResourcePackSendS2c;
-pub use scoreboard_display::ScoreboardDisplayS2c;
-pub use scoreboard_objective_update::ScoreboardObjectiveUpdateS2c;
-pub use scoreboard_player_update::ScoreboardPlayerUpdateS2c;
-pub use screen_handler_property_update::ScreenHandlerPropertyUpdateS2c;
-pub use screen_handler_slot_update::ScreenHandlerSlotUpdateS2c;
-pub use select_advancement_tab::SelectAdvancementTabS2c;
-pub use server_metadata::ServerMetadataS2c;
-pub use set_camera_entity::SetCameraEntityS2c;
-pub use set_trade_offers::SetTradeOffersS2c;
-pub use sign_editor_open::SignEditorOpenS2c;
-pub use simulation_distance::SimulationDistanceS2c;
-pub use statistics::StatisticsS2c;
-pub use stop_sound::StopSoundS2c;
-pub use subtitle::SubtitleS2c;
-pub use synchronize_recipes::SynchronizeRecipesS2c;
-pub use synchronize_tags::SynchronizeTagsS2c;
-pub use team::TeamS2c;
-pub use title::TitleS2c;
-pub use title_fade::TitleFadeS2c;
-pub use unload_chunk::UnloadChunkS2c;
-pub use unlock_recipes::UnlockRecipesS2c;
-pub use update_selected_slot::UpdateSelectedSlotS2c;
-pub use vehicle_move::VehicleMoveS2c;
-pub use world_border_center_changed::WorldBorderCenterChangedS2c;
-pub use world_border_initialize::WorldBorderInitializeS2c;
-pub use world_border_interpolate_size::WorldBorderInterpolateSizeS2c;
-pub use world_border_size_changed::WorldBorderSizeChangedS2c;
-pub use world_border_warning_blocks_changed::WorldBorderWarningBlocksChangedS2c;
-pub use world_border_warning_time_changed::WorldBorderWarningTimeChangedS2c;
-pub use world_event::WorldEventS2c;
-pub use world_time_update::WorldTimeUpdateS2c;
-
-pub mod advancement_update;
-pub mod block_breaking_progress;
-pub mod block_entity_update;
-pub mod block_event;
-pub mod block_update;
-pub mod boss_bar;
-pub mod bundle_splitter;
-pub mod chat_message;
-pub mod chat_suggestions;
-pub mod chunk_biome_data;
-pub mod chunk_data;
-pub mod chunk_delta_update;
-pub mod chunk_load_distance;
-pub mod chunk_render_distance_center;
-pub mod clear_title;
-pub mod close_screen;
-pub mod command_suggestions;
-pub mod command_tree;
-pub mod cooldown_update;
-pub mod craft_failed_response;
-pub mod custom_payload;
-pub mod damage_tilt;
-pub mod death_message;
-pub mod difficulty;
-pub mod disconnect;
-pub mod end_combat;
-pub mod enter_combat;
-pub mod entities_destroy;
-pub mod entity_animation;
-pub mod entity_attach;
-pub mod entity_attributes;
-pub mod entity_damage;
-pub mod entity_equipment_update;
-pub mod entity_move;
-pub mod entity_passengers_set;
-pub mod entity_position;
-pub mod entity_set_head_yaw;
-pub mod entity_spawn;
-pub mod entity_status;
-pub mod entity_status_effect;
-pub mod entity_tracker_update;
-pub mod entity_velocity_update;
-pub mod experience_bar_update;
-pub mod experience_orb_spawn;
-pub mod explosion;
-pub mod features;
-pub mod game_join;
-pub mod game_message;
-pub mod game_state_change;
-pub mod health_update;
-pub mod inventory;
-pub mod item_pickup_animation;
-pub mod keep_alive;
-pub mod light_update;
-pub mod look_at;
-pub mod map_update;
-pub mod nbt_query_response;
-pub mod open_horse_screen;
-pub mod open_screen;
-pub mod open_written_book;
-pub mod overlay_message;
-pub mod particle;
-pub mod play_ping;
-pub mod play_sound;
-pub mod play_sound_from_entity;
-pub mod player_abilities;
-pub mod player_action_response;
-pub mod player_list;
-pub mod player_list_header;
-pub mod player_position_look;
-pub mod player_remove;
-pub mod player_respawn;
-pub mod player_spawn;
-pub mod player_spawn_position;
-pub mod profileless_chat_message;
-pub mod remove_entity_status_effect;
-pub mod remove_message;
-pub mod resource_pack_send;
-pub mod scoreboard_display;
-pub mod scoreboard_objective_update;
-pub mod scoreboard_player_update;
-pub mod screen_handler_property_update;
-pub mod screen_handler_slot_update;
-pub mod select_advancement_tab;
-pub mod server_metadata;
-pub mod set_camera_entity;
-pub mod set_trade_offers;
-pub mod sign_editor_open;
-pub mod simulation_distance;
-pub mod statistics;
-pub mod stop_sound;
-pub mod subtitle;
-pub mod synchronize_recipes;
-pub mod synchronize_tags;
-pub mod team;
-pub mod title;
-pub mod title_fade;
-pub mod unload_chunk;
-pub mod unlock_recipes;
-pub mod update_selected_slot;
-pub mod vehicle_move;
-pub mod world_border_center_changed;
-pub mod world_border_initialize;
-pub mod world_border_interpolate_size;
-pub mod world_border_size_changed;
-pub mod world_border_warning_blocks_changed;
-pub mod world_border_warning_time_changed;
-pub mod world_event;
-pub mod world_time_update;
-
-packet_group! {
-    #[derive(Clone)]
-    S2cPlayPacket<'a> {
-        AdvancementUpdateS2c<'a>,
-        BlockBreakingProgressS2c,
-        BlockEntityUpdateS2c<'a>,
-        BlockEventS2c,
-        BlockUpdateS2c,
-        BossBarS2c,
-        BundleSplitter,
-        ChatMessageS2c<'a>,
-        ChatSuggestionsS2c<'a>,
-        ChunkBiomeDataS2c<'a>,
-        ChunkDataS2c<'a>,
-        ChunkDeltaUpdateS2c<'a>,
-        ChunkLoadDistanceS2c,
-        ChunkRenderDistanceCenterS2c,
-        ClearTitleS2c,
-        CloseScreenS2c,
-        CommandSuggestionsS2c<'a>,
-        CommandTreeS2c<'a>,
-        CooldownUpdateS2c,
-        CraftFailedResponseS2c<'a>,
-        CustomPayloadS2c<'a>,
-        DamageTiltS2c,
-        DeathMessageS2c<'a>,
-        DifficultyS2c,
-        DisconnectS2c<'a>,
-        EndCombatS2c,
-        EnterCombatS2c,
-        EntitiesDestroyS2c<'a>,
-        EntityAnimationS2c,
-        EntityAttachS2c,
-        EntityAttributesS2c<'a>,
-        EntityDamageS2c,
-        EntityEquipmentUpdateS2c,
-        EntityPassengersSetS2c,
-        EntityPositionS2c,
-        EntitySetHeadYawS2c,
-        EntitySpawnS2c,
-        EntityStatusEffectS2c,
-        EntityStatusS2c,
-        EntityTrackerUpdateS2c<'a>,
-        EntityVelocityUpdateS2c,
-        ExperienceBarUpdateS2c,
-        ExperienceOrbSpawnS2c,
-        ExplosionS2c<'a>,
-        FeaturesS2c<'a>,
-        GameJoinS2c<'a>,
-        GameMessageS2c<'a>,
-        GameStateChangeS2c,
-        HealthUpdateS2c,
-        InventoryS2c<'a>,
-        ItemPickupAnimationS2c,
-        KeepAliveS2c,
-        LightUpdateS2c,
-        LookAtS2c,
-        MapUpdateS2c<'a>,
-        MoveRelative,
-        NbtQueryResponseS2c,
-        OpenHorseScreenS2c,
-        OpenScreenS2c<'a>,
-        OpenWrittenBookS2c,
-        OverlayMessageS2c<'a>,
-        ParticleS2c<'a>,
-        PlayerAbilitiesS2c,
-        PlayerActionResponseS2c,
-        PlayerListHeaderS2c<'a>,
-        PlayerListS2c<'a>,
-        PlayerPositionLookS2c,
-        PlayerRemoveS2c<'a>,
-        PlayerRespawnS2c<'a>,
-        PlayerSpawnPositionS2c,
-        PlayerSpawnS2c,
-        PlayPingS2c,
-        PlaySoundFromEntityS2c,
-        PlaySoundS2c<'a>,
-        ProfilelessChatMessageS2c<'a>,
-        RemoveEntityStatusEffectS2c,
-        RemoveMessageS2c<'a>,
-        ResourcePackSendS2c<'a>,
-        Rotate,
-        RotateAndMoveRelative,
-        ScoreboardDisplayS2c<'a>,
-        ScoreboardObjectiveUpdateS2c<'a>,
-        ScoreboardPlayerUpdateS2c<'a>,
-        ScreenHandlerPropertyUpdateS2c,
-        ScreenHandlerSlotUpdateS2c<'a>,
-        SelectAdvancementTabS2c<'a>,
-        ServerMetadataS2c<'a>,
-        SetCameraEntityS2c,
-        SetTradeOffersS2c,
-        SignEditorOpenS2c,
-        SimulationDistanceS2c,
-        StatisticsS2c,
-        StopSoundS2c<'a>,
-        SubtitleS2c<'a>,
-        SynchronizeRecipesS2c<'a>,
-        SynchronizeTagsS2c<'a>,
-        TeamS2c<'a>,
-        TitleFadeS2c,
-        TitleS2c<'a>,
-        UnloadChunkS2c,
-        UnlockRecipesS2c<'a>,
-        UpdateSelectedSlotS2c,
-        VehicleMoveS2c,
-        WorldBorderCenterChangedS2c,
-        WorldBorderInitializeS2c,
-        WorldBorderInterpolateSizeS2c,
-        WorldBorderSizeChangedS2c,
-        WorldBorderWarningBlocksChangedS2c,
-        WorldBorderWarningTimeChangedS2c,
-        WorldEventS2c,
-        WorldTimeUpdateS2c,
-    }
-}
-*/
\ No newline at end of file
diff --git a/crates/valence_dimension/Cargo.toml b/crates/valence_dimension/Cargo.toml
index b7e884c..3aadc6a 100644
--- a/crates/valence_dimension/Cargo.toml
+++ b/crates/valence_dimension/Cargo.toml
@@ -7,7 +7,8 @@ edition.workspace = true
 anyhow.workspace = true
 bevy_app.workspace = true
 bevy_ecs.workspace = true
+serde.workspace = true
 tracing.workspace = true
-valence_registry.workspace = true
-valence_nbt.workspace = true
 valence_core.workspace = true
+valence_nbt = { workspace = true, features = ["serde"] }
+valence_registry.workspace = true
\ No newline at end of file
diff --git a/crates/valence_dimension/src/lib.rs b/crates/valence_dimension/src/lib.rs
index 7ff57e8..579ec7a 100644
--- a/crates/valence_dimension/src/lib.rs
+++ b/crates/valence_dimension/src/lib.rs
@@ -17,155 +17,39 @@
     clippy::dbg_macro
 )]
 
-use std::collections::BTreeMap;
-use std::str::FromStr;
+use std::ops::{Deref, DerefMut};
 
-use anyhow::{bail, Context};
 use bevy_app::prelude::*;
 use bevy_ecs::prelude::*;
+use serde::{Deserialize, Serialize};
 use tracing::{error, warn};
 use valence_core::ident;
 use valence_core::ident::Ident;
-use valence_nbt::{compound, Value};
-use valence_registry::{RegistryCodec, RegistryCodecSet, RegistryValue};
+use valence_nbt::serde::CompoundSerializer;
+use valence_registry::codec::{RegistryCodec, RegistryValue};
+use valence_registry::{Registry, RegistryIdx, RegistrySet};
 
 pub struct DimensionPlugin;
 
-#[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
-struct DimensionSet;
-
 impl Plugin for DimensionPlugin {
     fn build(&self, app: &mut App) {
-        app.insert_resource(DimensionTypeRegistry {
-            name_to_dimension: BTreeMap::new(),
-        })
-        .configure_set(
-            DimensionSet
-                .in_base_set(CoreSet::PostUpdate)
-                .before(RegistryCodecSet),
-        )
-        .add_systems(
-            (
-                update_dimension_type_registry,
-                remove_dimension_types_from_registry,
-            )
-                .chain()
-                .in_set(DimensionSet),
-        )
-        .add_startup_system(load_default_dimension_types.in_base_set(StartupSet::PreStartup));
+        app.init_resource::<DimensionTypeRegistry>()
+            .add_startup_system(load_default_dimension_types.in_base_set(StartupSet::PreStartup))
+            .add_system(
+                update_dimension_type_registry
+                    .in_base_set(CoreSet::PostUpdate)
+                    .before(RegistrySet),
+            );
     }
 }
 
-fn update_dimension_type_registry(
-    mut reg: ResMut<DimensionTypeRegistry>,
-    mut codec: ResMut<RegistryCodec>,
-    dimension_types: Query<(Entity, &DimensionType), Changed<DimensionType>>,
-) {
-    for (entity, dim) in &dimension_types {
-        // In case the name was changed.
-        reg.name_to_dimension.insert(dim.name.clone(), entity);
-
-        let dimension_type_compound = compound! {
-            "ambient_light" => dim.ambient_light,
-            "bed_works" => dim.bed_works,
-            "coordinate_scale" => dim.coordinate_scale,
-            "effects" => Ident::from(dim.effects),
-            "has_ceiling" => dim.has_ceiling,
-            "has_raids" => dim.has_raids,
-            "has_skylight" => dim.has_skylight,
-            "height" => dim.height,
-            "infiniburn" => &dim.infiniburn,
-            "logical_height" => dim.logical_height,
-            "min_y" => dim.min_y,
-            "monster_spawn_block_light_limit" => dim.monster_spawn_block_light_limit,
-            "natural" => dim.natural,
-            "piglin_safe" => dim.piglin_safe,
-            "respawn_anchor_works" => dim.respawn_anchor_works,
-            "ultrawarm" => dim.ultrawarm,
-        };
-
-        let dimension_type_reg = codec.registry_mut(DimensionTypeRegistry::KEY);
-
-        if let Some(value) = dimension_type_reg.iter_mut().find(|v| v.name == dim.name) {
-            value.name = dim.name.clone();
-            value.element.merge(dimension_type_compound);
-        } else {
-            dimension_type_reg.push(RegistryValue {
-                name: dim.name.clone(),
-                element: dimension_type_compound,
-            });
-        }
-    }
-}
-
-fn remove_dimension_types_from_registry(
-    mut reg: ResMut<DimensionTypeRegistry>,
-    mut codec: ResMut<RegistryCodec>,
-    mut dimension_types: RemovedComponents<DimensionType>,
-) {
-    for entity in dimension_types.iter() {
-        if let Some((name, _)) = reg.name_to_dimension.iter().find(|(_, &e)| e == entity) {
-            let name = name.clone();
-            reg.name_to_dimension.remove(name.as_str());
-
-            let dimension_type_reg = codec.registry_mut(DimensionTypeRegistry::KEY);
-
-            if let Some(idx) = dimension_type_reg.iter().position(|v| v.name == name) {
-                dimension_type_reg.remove(idx);
-            }
-        }
-    }
-}
-
-fn load_default_dimension_types(
-    mut reg: ResMut<DimensionTypeRegistry>,
-    codec: Res<RegistryCodec>,
-    mut commands: Commands,
-) {
+/// Loads the default dimension types from the registry codec.
+fn load_default_dimension_types(mut reg: ResMut<DimensionTypeRegistry>, codec: Res<RegistryCodec>) {
     let mut helper = move || -> anyhow::Result<()> {
         for value in codec.registry(DimensionTypeRegistry::KEY) {
-            macro_rules! get {
-                ($name:literal, $f:expr) => {{
-                    value
-                        .element
-                        .get($name)
-                        .and_then($f)
-                        .context(concat!("invalid ", $name))?
-                }};
-            }
+            let dimension_type = DimensionType::deserialize(value.element.clone())?;
 
-            let entity = commands
-                .spawn(DimensionType {
-                    name: value.name.clone(),
-                    ambient_light: *get!("ambient_light", Value::as_float),
-                    bed_works: *get!("bed_works", Value::as_byte) != 0,
-                    coordinate_scale: *get!("coordinate_scale", Value::as_double),
-                    effects: DimensionEffects::from_str(get!("effects", Value::as_string))?,
-                    has_ceiling: *get!("has_ceiling", Value::as_byte) != 0,
-                    has_raids: *get!("has_raids", Value::as_byte) != 0,
-                    has_skylight: *get!("has_skylight", Value::as_byte) != 0,
-                    height: *get!("height", Value::as_int),
-                    infiniburn: get!("infiniburn", Value::as_string).clone(),
-                    logical_height: *get!("logical_height", Value::as_int),
-                    min_y: *get!("min_y", Value::as_int),
-                    monster_spawn_block_light_limit: *get!(
-                        "monster_spawn_block_light_limit",
-                        Value::as_int
-                    ),
-                    natural: *get!("natural", Value::as_byte) != 0,
-                    piglin_safe: *get!("piglin_safe", Value::as_byte) != 0,
-                    respawn_anchor_works: *get!("respawn_anchor_works", Value::as_byte) != 0,
-                    ultrawarm: *get!("ultrawarm", Value::as_byte) != 0,
-                })
-                .id();
-
-            if reg
-                .name_to_dimension
-                .insert(value.name.clone(), entity)
-                .is_some()
-            {
-                warn!("duplicate dimension type name of \"{}\"", &value.name);
-            }
+            reg.insert(value.name.clone(), dimension_type);
         }
 
         Ok(())
@@ -176,30 +60,75 @@ fn load_default_dimension_types(
     }
 }
 
-#[derive(Resource)]
+/// Updates the registry codec as the dimension type registry is modified by
+/// users.
+fn update_dimension_type_registry(
+    reg: Res<DimensionTypeRegistry>,
+    mut codec: ResMut<RegistryCodec>,
+) {
+    if reg.is_changed() {
+        let dimension_types = codec.registry_mut(DimensionTypeRegistry::KEY);
+
+        dimension_types.clear();
+
+        dimension_types.extend(reg.iter().map(|(_, name, dim)| {
+            RegistryValue {
+                name: name.into(),
+                element: dim
+                    .serialize(CompoundSerializer)
+                    .expect("failed to serialize dimension type"),
+            }
+        }));
+    }
+}
+
+#[derive(Resource, Default, Debug)]
 pub struct DimensionTypeRegistry {
-    name_to_dimension: BTreeMap<Ident<String>, Entity>,
+    reg: Registry<DimensionTypeId, DimensionType>,
 }
 
 impl DimensionTypeRegistry {
-    pub const KEY: Ident<&str> = ident!("minecraft:dimension_type");
+    pub const KEY: Ident<&str> = ident!("dimension_type");
+}
 
-    pub fn get_by_name(&self, name: Ident<&str>) -> Option<Entity> {
-        self.name_to_dimension.get(name.as_str()).copied()
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)]
+pub struct DimensionTypeId(u16);
+
+impl RegistryIdx for DimensionTypeId {
+    const MAX: usize = u16::MAX as _;
+
+    fn to_index(self) -> usize {
+        self.0 as _
     }
 
-    pub fn dimensions(&self) -> impl Iterator<Item = Entity> + '_ {
-        self.name_to_dimension.values().copied()
+    fn from_index(idx: usize) -> Self {
+        Self(idx as _)
     }
 }
 
-#[derive(Component, Clone, PartialEq, Debug)]
+impl Deref for DimensionTypeRegistry {
+    type Target = Registry<DimensionTypeId, DimensionType>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.reg
+    }
+}
+
+impl DerefMut for DimensionTypeRegistry {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.reg
+    }
+}
+
+#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
+#[serde(deny_unknown_fields)]
 pub struct DimensionType {
-    pub name: Ident<String>,
     pub ambient_light: f32,
     pub bed_works: bool,
     pub coordinate_scale: f64,
     pub effects: DimensionEffects,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub fixed_time: Option<i32>,
     pub has_ceiling: bool,
     pub has_raids: bool,
     pub has_skylight: bool,
@@ -208,7 +137,7 @@ pub struct DimensionType {
     pub logical_height: i32,
     pub min_y: i32,
     pub monster_spawn_block_light_limit: i32,
-    /// TODO: monster_spawn_light_level
+    pub monster_spawn_light_level: MonsterSpawnLightLevel,
     pub natural: bool,
     pub piglin_safe: bool,
     pub respawn_anchor_works: bool,
@@ -218,11 +147,11 @@ pub struct DimensionType {
 impl Default for DimensionType {
     fn default() -> Self {
         Self {
-            name: ident!("minecraft:overworld").into(),
             ambient_light: 1.0,
             bed_works: true,
             coordinate_scale: 1.0,
             effects: DimensionEffects::default(),
+            fixed_time: None,
             has_ceiling: false,
             has_raids: true,
             has_skylight: true,
@@ -231,42 +160,46 @@ impl Default for DimensionType {
             logical_height: 384,
             min_y: -64,
             monster_spawn_block_light_limit: 0,
+            monster_spawn_light_level: MonsterSpawnLightLevel::Int(7),
             natural: true,
             piglin_safe: false,
-            respawn_anchor_works: true,
+            respawn_anchor_works: false,
             ultrawarm: false,
         }
     }
 }
 
 /// Determines what skybox/fog effects to use in dimensions.
-#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
+#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
 pub enum DimensionEffects {
+    #[serde(rename = "minecraft:overworld")]
     #[default]
     Overworld,
+    #[serde(rename = "minecraft:the_nether")]
     TheNether,
+    #[serde(rename = "minecraft:the_end")]
     TheEnd,
 }
 
-impl From<DimensionEffects> for Ident<&'static str> {
-    fn from(value: DimensionEffects) -> Self {
-        match value {
-            DimensionEffects::Overworld => ident!("overworld"),
-            DimensionEffects::TheNether => ident!("the_nether"),
-            DimensionEffects::TheEnd => ident!("the_end"),
-        }
-    }
+#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum MonsterSpawnLightLevel {
+    Int(i32),
+    Tagged(MonsterSpawnLightLevelTagged),
 }
 
-impl FromStr for DimensionEffects {
-    type Err = anyhow::Error;
+#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)]
+#[serde(tag = "type", content = "value")]
+pub enum MonsterSpawnLightLevelTagged {
+    #[serde(rename = "minecraft:uniform")]
+    Uniform {
+        min_inclusive: i32,
+        max_inclusive: i32,
+    },
+}
 
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match Ident::new(s)?.as_str() {
-            "minecraft:overworld" => Ok(DimensionEffects::Overworld),
-            "minecraft:the_nether" => Ok(DimensionEffects::TheNether),
-            "minecraft:the_end" => Ok(DimensionEffects::TheEnd),
-            other => bail!("unknown dimension effect \"{other}\""),
-        }
+impl From<i32> for MonsterSpawnLightLevel {
+    fn from(value: i32) -> Self {
+        Self::Int(value)
     }
 }
diff --git a/crates/valence_instance/Cargo.toml b/crates/valence_instance/Cargo.toml
index 5e277fa..6f9266b 100644
--- a/crates/valence_instance/Cargo.toml
+++ b/crates/valence_instance/Cargo.toml
@@ -19,3 +19,4 @@ valence_core.workspace = true
 valence_dimension.workspace = true
 valence_entity.workspace = true
 valence_nbt.workspace = true
+valence_registry.workspace = true
diff --git a/crates/valence_instance/src/chunk.rs b/crates/valence_instance/src/chunk.rs
index 7df31ef..4e364a1 100644
--- a/crates/valence_instance/src/chunk.rs
+++ b/crates/valence_instance/src/chunk.rs
@@ -13,6 +13,7 @@ use valence_core::protocol::var_int::VarInt;
 use valence_core::protocol::var_long::VarLong;
 use valence_core::protocol::Encode;
 use valence_nbt::{compound, Compound};
+use valence_registry::RegistryIdx;
 
 use crate::packet::{
     BlockEntityUpdateS2c, BlockUpdateS2c, ChunkDataBlockEntity, ChunkDataS2c, ChunkDeltaUpdateS2c,
@@ -414,7 +415,7 @@ impl Chunk<true> {
                 sect.biomes
                     .encode_mc_format(
                         &mut *scratch,
-                        |b| b.0.into(),
+                        |b| b.to_index() as _,
                         0,
                         3,
                         bit_width(info.biome_registry_len - 1),
diff --git a/crates/valence_instance/src/lib.rs b/crates/valence_instance/src/lib.rs
index 34b43a5..862e31e 100644
--- a/crates/valence_instance/src/lib.rs
+++ b/crates/valence_instance/src/lib.rs
@@ -31,7 +31,7 @@ pub use chunk_entry::*;
 use glam::{DVec3, Vec3};
 use num_integer::div_ceil;
 use rustc_hash::FxHashMap;
-use valence_biome::Biome;
+use valence_biome::BiomeRegistry;
 use valence_core::block_pos::BlockPos;
 use valence_core::chunk_pos::ChunkPos;
 use valence_core::despawn::Despawned;
@@ -44,7 +44,7 @@ use valence_core::protocol::packet::sound::{PlaySoundS2c, Sound, SoundCategory};
 use valence_core::protocol::var_int::VarInt;
 use valence_core::protocol::{Encode, Packet};
 use valence_core::Server;
-use valence_dimension::DimensionType;
+use valence_dimension::DimensionTypeRegistry;
 use valence_entity::packet::{
     EntityAnimationS2c, EntityPositionS2c, EntitySetHeadYawS2c, EntityStatusS2c,
     EntityTrackerUpdateS2c, EntityVelocityUpdateS2c, MoveRelativeS2c, RotateAndMoveRelativeS2c,
@@ -432,40 +432,33 @@ pub struct InstanceInfo {
 #[derive(Debug)]
 pub struct PartitionCell {
     /// The chunk in this cell.
-    #[doc(hidden)]
     pub chunk: Option<Chunk<true>>,
     /// If `chunk` went from `Some` to `None` this tick.
-    #[doc(hidden)]
     pub chunk_removed: bool,
     /// Minecraft entities in this cell.
-    #[doc(hidden)]
     pub entities: BTreeSet<Entity>,
     /// Minecraft entities that have entered the chunk this tick, paired with
     /// the cell position in this instance they came from.
-    #[doc(hidden)]
     pub incoming: Vec<(Entity, Option<ChunkPos>)>,
     /// Minecraft entities that have left the chunk this tick, paired with the
     /// cell position in this world they arrived at.
-    #[doc(hidden)]
     pub outgoing: Vec<(Entity, Option<ChunkPos>)>,
     /// A cache of packets to send to all clients that are in view of this cell
     /// at the end of the tick.
-    #[doc(hidden)]
     pub packet_buf: Vec<u8>,
 }
 
 impl Instance {
+    #[track_caller]
     pub fn new(
         dimension_type_name: impl Into<Ident<String>>,
-        dimensions: &Query<&DimensionType>,
-        biomes: &Query<&Biome>,
+        dimensions: &DimensionTypeRegistry,
+        biomes: &BiomeRegistry,
         server: &Server,
     ) -> Self {
         let dimension_type_name = dimension_type_name.into();
 
-        let Some(dim) = dimensions.iter().find(|d| d.name == dimension_type_name) else {
-            panic!("missing dimension type with name \"{dimension_type_name}\"")
-        };
+        let dim = &dimensions[dimension_type_name.as_str_ident()];
 
         assert!(dim.height > 0, "invalid dimension height of {}", dim.height);
 
@@ -497,28 +490,6 @@ impl Instance {
         }
     }
 
-    /// TODO: Temporary hack for unit testing. Do not use!
-    #[doc(hidden)]
-    pub fn new_unit_testing(
-        dimension_type_name: impl Into<Ident<String>>,
-        server: &Server,
-    ) -> Self {
-        Self {
-            partition: FxHashMap::default(),
-            info: InstanceInfo {
-                dimension_type_name: dimension_type_name.into(),
-                section_count: 24,
-                min_y: -64,
-                biome_registry_len: 1,
-                compression_threshold: server.compression_threshold(),
-                filler_sky_light_mask: vec![].into(),
-                filler_sky_light_arrays: vec![].into(),
-            },
-            packet_buf: vec![],
-            scratch: vec![],
-        }
-    }
-
     pub fn dimension_type_name(&self) -> Ident<&str> {
         self.info.dimension_type_name.as_str_ident()
     }
diff --git a/crates/valence_nbt/src/serde.rs b/crates/valence_nbt/src/serde.rs
index 7dd780f..7574b2b 100644
--- a/crates/valence_nbt/src/serde.rs
+++ b/crates/valence_nbt/src/serde.rs
@@ -6,6 +6,8 @@ use thiserror::Error;
 
 mod de;
 mod ser;
+#[cfg(test)]
+mod tests;
 
 /// Errors that can occur while serializing or deserializing.
 #[derive(Clone, Error, Debug)]
@@ -56,105 +58,3 @@ fn i8_vec_to_u8_vec(vec: Vec<i8>) -> Vec<u8> {
         Vec::from_raw_parts(vec.as_mut_ptr() as *mut u8, vec.len(), vec.capacity())
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use pretty_assertions::assert_eq;
-    use serde::{Deserialize, Serialize};
-    use serde_json::json;
-
-    use super::*;
-    use crate::{compound, Compound, List};
-
-    #[derive(Serialize, Deserialize, PartialEq, Debug)]
-    struct Struct {
-        foo: i32,
-        bar: StructInner,
-        baz: String,
-        quux: Vec<f32>,
-    }
-
-    #[derive(Serialize, Deserialize, PartialEq, Debug)]
-    struct StructInner {
-        a: bool,
-        b: i64,
-        c: Vec<Vec<i32>>,
-        d: Vec<StructInner>,
-    }
-
-    fn make_struct() -> Struct {
-        Struct {
-            foo: i32::MIN,
-            bar: StructInner {
-                a: true,
-                b: 123456789,
-                c: vec![vec![1, 2, 3], vec![4, 5, 6]],
-                d: vec![],
-            },
-            baz: "🤨".into(),
-            quux: vec![std::f32::consts::PI, f32::MAX, f32::MIN],
-        }
-    }
-
-    fn make_compound() -> Compound {
-        compound! {
-            "foo" => i32::MIN,
-            "bar" => compound! {
-                "a" => true,
-                "b" => 123456789_i64,
-                "c" => List::IntArray(vec![vec![1, 2, 3], vec![4, 5, 6]]),
-                "d" => List::End,
-            },
-            "baz" => "🤨",
-            "quux" => List::Float(vec![
-                std::f32::consts::PI,
-                f32::MAX,
-                f32::MIN,
-            ]),
-        }
-    }
-
-    fn make_json() -> serde_json::Value {
-        json!({
-            "foo": i32::MIN,
-            "bar": {
-                "a": true,
-                "b": 123456789_i64,
-                "c": [[1, 2, 3], [4, 5, 6]],
-                "d": []
-            },
-            "baz": "🤨",
-            "quux": [
-                std::f32::consts::PI,
-                f32::MAX,
-                f32::MIN,
-            ]
-        })
-    }
-
-    #[test]
-    fn struct_to_compound() {
-        let c = make_struct().serialize(CompoundSerializer).unwrap();
-
-        assert_eq!(c, make_compound());
-    }
-
-    #[test]
-    fn compound_to_struct() {
-        let s = Struct::deserialize(make_compound()).unwrap();
-
-        assert_eq!(s, make_struct());
-    }
-
-    #[test]
-    fn compound_to_json() {
-        let mut j = serde_json::to_value(make_compound()).unwrap();
-
-        // Bools map to bytes in NBT, but the result should be the same otherwise.
-        let p = j.pointer_mut("/bar/a").unwrap();
-        assert_eq!(*p, serde_json::Value::from(1));
-        *p = true.into();
-
-        assert_eq!(j, make_json());
-    }
-}
diff --git a/crates/valence_nbt/src/serde/de.rs b/crates/valence_nbt/src/serde/de.rs
index 199618a..da01782 100644
--- a/crates/valence_nbt/src/serde/de.rs
+++ b/crates/valence_nbt/src/serde/de.rs
@@ -288,10 +288,32 @@ impl<'de> Deserializer<'de> for Value {
         }
     }
 
+    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+    where
+        V: Visitor<'de>,
+    {
+        visitor.visit_some(self)
+    }
+
+    fn deserialize_enum<V>(
+        self,
+        _name: &'static str,
+        _variants: &'static [&'static str],
+        visitor: V,
+    ) -> Result<V::Value, Self::Error>
+    where
+        V: Visitor<'de>,
+    {
+        match self {
+            Value::String(s) => visitor.visit_enum(s.into_deserializer()), // Unit variant.
+            other => other.deserialize_any(visitor),
+        }
+    }
+
     forward_to_deserialize_any! {
         i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string
-        bytes byte_buf option unit unit_struct newtype_struct seq tuple
-        tuple_struct map struct enum identifier ignored_any
+        bytes byte_buf unit unit_struct newtype_struct seq tuple
+        tuple_struct map struct identifier ignored_any
     }
 }
 
diff --git a/crates/valence_nbt/src/serde/ser.rs b/crates/valence_nbt/src/serde/ser.rs
index 941beff..29993ce 100644
--- a/crates/valence_nbt/src/serde/ser.rs
+++ b/crates/valence_nbt/src/serde/ser.rs
@@ -324,11 +324,11 @@ impl Serializer for ValueSerializer {
         unsupported!("none")
     }
 
-    fn serialize_some<T: ?Sized>(self, _value: &T) -> Result<Self::Ok, Self::Error>
+    fn serialize_some<T: ?Sized>(self, value: &T) -> Result<Self::Ok, Self::Error>
     where
         T: Serialize,
     {
-        unsupported!("some")
+        value.serialize(self)
     }
 
     fn serialize_unit(self) -> Result<Self::Ok, Self::Error> {
@@ -343,9 +343,9 @@ impl Serializer for ValueSerializer {
         self,
         _name: &'static str,
         _variant_index: u32,
-        _variant: &'static str,
+        variant: &'static str,
     ) -> Result<Self::Ok, Self::Error> {
-        unsupported!("unit variant")
+        Ok(Value::String(variant.into()))
     }
 
     fn serialize_newtype_struct<T: ?Sized>(
diff --git a/crates/valence_nbt/src/serde/tests.rs b/crates/valence_nbt/src/serde/tests.rs
new file mode 100644
index 0000000..ab4dd2d
--- /dev/null
+++ b/crates/valence_nbt/src/serde/tests.rs
@@ -0,0 +1,109 @@
+use pretty_assertions::assert_eq;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+use super::*;
+use crate::{compound, Compound, List};
+
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+struct Struct {
+    foo: i32,
+    bar: StructInner,
+    baz: String,
+    quux: Vec<f32>,
+    blah: EnumInner,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+struct StructInner {
+    a: bool,
+    b: i64,
+    c: Vec<Vec<i32>>,
+    d: Vec<StructInner>,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+enum EnumInner {
+    A,
+    B,
+    C,
+}
+
+fn make_struct() -> Struct {
+    Struct {
+        foo: i32::MIN,
+        bar: StructInner {
+            a: true,
+            b: 123456789,
+            c: vec![vec![1, 2, 3], vec![4, 5, 6]],
+            d: vec![],
+        },
+        baz: "🤨".into(),
+        quux: vec![std::f32::consts::PI, f32::MAX, f32::MIN],
+        blah: EnumInner::B,
+    }
+}
+
+fn make_compound() -> Compound {
+    compound! {
+        "foo" => i32::MIN,
+        "bar" => compound! {
+            "a" => true,
+            "b" => 123456789_i64,
+            "c" => List::IntArray(vec![vec![1, 2, 3], vec![4, 5, 6]]),
+            "d" => List::End,
+        },
+        "baz" => "🤨",
+        "quux" => List::Float(vec![
+            std::f32::consts::PI,
+            f32::MAX,
+            f32::MIN,
+        ]),
+        "blah" => "B"
+    }
+}
+
+fn make_json() -> serde_json::Value {
+    json!({
+        "foo": i32::MIN,
+        "bar": {
+            "a": true,
+            "b": 123456789_i64,
+            "c": [[1, 2, 3], [4, 5, 6]],
+            "d": []
+        },
+        "baz": "🤨",
+        "quux": [
+            std::f32::consts::PI,
+            f32::MAX,
+            f32::MIN,
+        ],
+        "blah": "B"
+    })
+}
+
+#[test]
+fn struct_to_compound() {
+    let c = make_struct().serialize(CompoundSerializer).unwrap();
+
+    assert_eq!(c, make_compound());
+}
+
+#[test]
+fn compound_to_struct() {
+    let s = Struct::deserialize(make_compound()).unwrap();
+
+    assert_eq!(s, make_struct());
+}
+
+#[test]
+fn compound_to_json() {
+    let mut j = serde_json::to_value(make_compound()).unwrap();
+
+    // Bools map to bytes in NBT, but the result should be the same otherwise.
+    let p = j.pointer_mut("/bar/a").unwrap();
+    assert_eq!(*p, serde_json::Value::from(1));
+    *p = true.into();
+
+    assert_eq!(j, make_json());
+}
diff --git a/crates/valence_registry/Cargo.toml b/crates/valence_registry/Cargo.toml
index 02cf1af..2dba597 100644
--- a/crates/valence_registry/Cargo.toml
+++ b/crates/valence_registry/Cargo.toml
@@ -4,10 +4,11 @@ version.workspace = true
 edition.workspace = true
 
 [dependencies]
+bevy_app.workspace = true
+bevy_ecs.workspace = true
+indexmap.workspace = true
+serde_json.workspace = true
+serde.workspace = true
 tracing.workspace = true
 valence_core.workspace = true
-valence_nbt.workspace = true
-bevy_ecs.workspace = true
-bevy_app.workspace = true
-serde.workspace = true
-serde_json.workspace = true
\ No newline at end of file
+valence_nbt.workspace = true
\ No newline at end of file
diff --git a/crates/valence_registry/src/codec.rs b/crates/valence_registry/src/codec.rs
new file mode 100644
index 0000000..e3d56bf
--- /dev/null
+++ b/crates/valence_registry/src/codec.rs
@@ -0,0 +1,137 @@
+use std::collections::BTreeMap;
+
+use bevy_app::prelude::*;
+use bevy_ecs::prelude::*;
+use tracing::error;
+use valence_core::ident::Ident;
+use valence_nbt::{compound, Compound, List, Value};
+
+use crate::RegistrySet;
+
+pub(super) fn build(app: &mut App) {
+    app.init_resource::<RegistryCodec>()
+        .add_system(cache_registry_codec.in_set(RegistrySet));
+}
+
+/// Contains the registry codec sent to all players while joining. This contains
+/// information for biomes and dimensions among other things.
+///
+/// Generally, end users should not manipulate the registry codec directly. Use
+/// one of the other registry resources instead.
+#[derive(Resource, Debug)]
+pub struct RegistryCodec {
+    pub registries: BTreeMap<Ident<String>, Vec<RegistryValue>>,
+    // TODO: store this in binary form?
+    cached_codec: Compound,
+}
+
+#[derive(Clone, Debug)]
+pub struct RegistryValue {
+    pub name: Ident<String>,
+    pub element: Compound,
+}
+
+impl RegistryCodec {
+    pub fn cached_codec(&self) -> &Compound {
+        &self.cached_codec
+    }
+
+    pub fn registry(&self, registry_key: Ident<&str>) -> &Vec<RegistryValue> {
+        self.registries
+            .get(registry_key.as_str())
+            .unwrap_or_else(|| panic!("missing registry for {registry_key}"))
+    }
+
+    pub fn registry_mut(&mut self, registry_key: Ident<&str>) -> &mut Vec<RegistryValue> {
+        self.registries
+            .get_mut(registry_key.as_str())
+            .unwrap_or_else(|| panic!("missing registry for {registry_key}"))
+    }
+}
+
+impl Default for RegistryCodec {
+    fn default() -> Self {
+        let codec = include_bytes!("../../../extracted/registry_codec_1.19.4.dat");
+        let compound = Compound::from_binary(&mut codec.as_slice())
+            .expect("failed to decode vanilla registry codec")
+            .0;
+
+        let mut registries = BTreeMap::new();
+
+        for (k, v) in compound {
+            let reg_name: Ident<String> = Ident::new(k).expect("invalid registry name").into();
+            let mut reg_values = vec![];
+
+            let Value::Compound(mut outer) = v else {
+                error!("registry {reg_name} is not a compound");
+                continue
+            };
+
+            let values = match outer.remove("value") {
+                Some(Value::List(List::Compound(values))) => values,
+                Some(Value::List(List::End)) => continue,
+                _ => {
+                    error!("missing \"value\" compound in {reg_name}");
+                    continue;
+                }
+            };
+
+            for mut value in values {
+                let Some(Value::String(name)) = value.remove("name") else {
+                    error!("missing \"name\" string in value for {reg_name}");
+                    continue
+                };
+
+                let name = match Ident::new(name) {
+                    Ok(n) => n.into(),
+                    Err(e) => {
+                        error!("invalid registry value name \"{}\"", e.0);
+                        continue;
+                    }
+                };
+
+                let Some(Value::Compound(element)) = value.remove("element") else {
+                    error!("missing \"element\" compound in value for {reg_name}");
+                    continue
+                };
+
+                reg_values.push(RegistryValue { name, element });
+            }
+
+            registries.insert(reg_name, reg_values);
+        }
+
+        Self {
+            registries,
+            // Cache will be created later.
+            cached_codec: Compound::new(),
+        }
+    }
+}
+
+fn cache_registry_codec(codec: ResMut<RegistryCodec>) {
+    if codec.is_changed() {
+        let codec = codec.into_inner();
+
+        codec.cached_codec.clear();
+
+        for (reg_name, reg) in &codec.registries {
+            let mut value = vec![];
+
+            for (id, v) in reg.iter().enumerate() {
+                value.push(compound! {
+                    "id" => id as i32,
+                    "name" => v.name.as_str(),
+                    "element" => v.element.clone(),
+                });
+            }
+
+            let registry = compound! {
+                "type" => reg_name.as_str(),
+                "value" => List::Compound(value),
+            };
+
+            codec.cached_codec.insert(reg_name.as_str(), registry);
+        }
+    }
+}
diff --git a/crates/valence_registry/src/lib.rs b/crates/valence_registry/src/lib.rs
index 0d9bdaa..b0855cb 100644
--- a/crates/valence_registry/src/lib.rs
+++ b/crates/valence_registry/src/lib.rs
@@ -17,155 +17,163 @@
     clippy::dbg_macro
 )]
 
-use std::collections::BTreeMap;
+pub mod codec;
+pub mod tags;
+
+use std::fmt::Debug;
+use std::hash::Hash;
+use std::marker::PhantomData;
+use std::ops::{Index, IndexMut};
 
 use bevy_app::prelude::*;
 pub use bevy_ecs::prelude::*;
-use tracing::error;
+use indexmap::map::Entry;
+use indexmap::IndexMap;
 use valence_core::ident::Ident;
-use valence_nbt::{compound, Compound, List, Value};
-
-mod tags;
-
-pub use tags::*;
 
 pub struct RegistryPlugin;
 
-/// The [`SystemSet`] where the [`RegistryCodec`] cache is rebuilt. Systems that
-/// modify the registry codec should run _before_ this.
+/// The [`SystemSet`] where the [`RegistryCodec`](codec::RegistryCodec) and
+/// [`TagsRegistry`](tags::TagsRegistry) caches are rebuilt. Systems that modify
+/// the registry codec or tags registry should run _before_ this.
 #[derive(SystemSet, Copy, Clone, PartialEq, Eq, Hash, Debug)]
-pub struct RegistryCodecSet;
+pub struct RegistrySet;
 
 impl Plugin for RegistryPlugin {
     fn build(&self, app: &mut bevy_app::App) {
-        app.init_resource::<RegistryCodec>()
-            .init_resource::<TagsRegistry>()
-            .configure_set(RegistryCodecSet.in_base_set(CoreSet::PostUpdate))
-            .add_startup_system(init_tags_registry.in_set(RegistryCodecSet))
-            .add_system(cache_registry_codec.in_set(RegistryCodecSet))
-            .add_system(cache_tags_packet.in_set(RegistryCodecSet));
+        app.configure_set(RegistrySet.in_base_set(CoreSet::PostUpdate));
+
+        codec::build(app);
+        tags::build(app);
     }
 }
 
-fn cache_registry_codec(codec: ResMut<RegistryCodec>) {
-    if codec.is_changed() {
-        let codec = codec.into_inner();
-
-        codec.cached_codec.clear();
-
-        for (reg_name, reg) in &codec.registries {
-            let mut value = vec![];
-
-            for (id, v) in reg.iter().enumerate() {
-                value.push(compound! {
-                    "id" => id as i32,
-                    "name" => v.name.as_str(),
-                    "element" => v.element.clone(),
-                });
-            }
-
-            let registry = compound! {
-                "type" => reg_name.as_str(),
-                "value" => List::Compound(value),
-            };
-
-            codec.cached_codec.insert(reg_name.as_str(), registry);
-        }
-    }
-}
-
-/// Contains the registry codec sent to all players while joining. This contains
-/// information for biomes and dimensions among other things.
-///
-/// Generally, end users should not manipulate the registry codec directly. Use
-/// one of the other modules instead.
-#[derive(Resource, Debug)]
-pub struct RegistryCodec {
-    pub registries: BTreeMap<Ident<String>, Vec<RegistryValue>>,
-    // TODO: store this in binary form?
-    cached_codec: Compound,
-}
-
 #[derive(Clone, Debug)]
-pub struct RegistryValue {
-    pub name: Ident<String>,
-    pub element: Compound,
+pub struct Registry<I, V> {
+    items: IndexMap<Ident<String>, V>,
+    _marker: PhantomData<I>,
 }
 
-impl RegistryCodec {
-    pub fn cached_codec(&self) -> &Compound {
-        &self.cached_codec
-    }
-
-    pub fn registry(&self, registry_key: Ident<&str>) -> &Vec<RegistryValue> {
-        self.registries
-            .get(registry_key.as_str())
-            .unwrap_or_else(|| panic!("missing registry for {registry_key}"))
-    }
-
-    pub fn registry_mut(&mut self, registry_key: Ident<&str>) -> &mut Vec<RegistryValue> {
-        self.registries
-            .get_mut(registry_key.as_str())
-            .unwrap_or_else(|| panic!("missing registry for {registry_key}"))
-    }
-}
-
-impl Default for RegistryCodec {
-    fn default() -> Self {
-        let codec = include_bytes!("../../../extracted/registry_codec_1.19.4.dat");
-        let compound = Compound::from_binary(&mut codec.as_slice())
-            .expect("failed to decode vanilla registry codec")
-            .0;
-
-        let mut registries = BTreeMap::new();
-
-        for (k, v) in compound {
-            let reg_name: Ident<String> = Ident::new(k).expect("invalid registry name").into();
-            let mut reg_values = vec![];
-
-            let Value::Compound(mut outer) = v else {
-                error!("registry {reg_name} is not a compound");
-                continue
-            };
-
-            let values = match outer.remove("value") {
-                Some(Value::List(List::Compound(values))) => values,
-                Some(Value::List(List::End)) => continue,
-                _ => {
-                    error!("missing \"value\" compound in {reg_name}");
-                    continue;
-                }
-            };
-
-            for mut value in values {
-                let Some(Value::String(name)) = value.remove("name") else {
-                    error!("missing \"name\" string in value for {reg_name}");
-                    continue
-                };
-
-                let name = match Ident::new(name) {
-                    Ok(n) => n.into(),
-                    Err(e) => {
-                        error!("invalid registry value name \"{}\"", e.0);
-                        continue;
-                    }
-                };
-
-                let Some(Value::Compound(element)) = value.remove("element") else {
-                    error!("missing \"element\" compound in value for {reg_name}");
-                    continue
-                };
-
-                reg_values.push(RegistryValue { name, element });
-            }
-
-            registries.insert(reg_name, reg_values);
-        }
-
+impl<I: RegistryIdx, V> Registry<I, V> {
+    pub fn new() -> Self {
         Self {
-            registries,
-            // Cache will be created later.
-            cached_codec: Compound::new(),
+            items: IndexMap::new(),
+            _marker: PhantomData,
+        }
+    }
+
+    pub fn insert(&mut self, name: impl Into<Ident<String>>, item: V) -> Option<I> {
+        if self.items.len() >= I::MAX {
+            // Too many items in the registry.
+            return None;
+        }
+
+        let len = self.items.len();
+
+        match self.items.entry(name.into()) {
+            Entry::Occupied(_) => None,
+            Entry::Vacant(ve) => {
+                ve.insert(item);
+                Some(I::from_index(len))
+            }
+        }
+    }
+
+    pub fn swap_to_front(&mut self, name: Ident<&str>) {
+        if let Some(idx) = self.items.get_index_of(name.as_str()) {
+            self.items.swap_indices(0, idx);
+        }
+    }
+
+    pub fn remove(&mut self, name: Ident<&str>) -> Option<V> {
+        self.items.shift_remove(name.as_str())
+    }
+
+    pub fn get(&self, name: Ident<&str>) -> Option<&V> {
+        self.items.get(name.as_str())
+    }
+
+    pub fn get_mut(&mut self, name: Ident<&str>) -> Option<&mut V> {
+        self.items.get_mut(name.as_str())
+    }
+
+    pub fn index_of(&self, name: Ident<&str>) -> Option<I> {
+        self.items.get_index_of(name.as_str()).map(I::from_index)
+    }
+
+    pub fn iter(
+        &self,
+    ) -> impl DoubleEndedIterator<Item = (I, Ident<&str>, &V)> + ExactSizeIterator + '_ {
+        self.items
+            .iter()
+            .enumerate()
+            .map(|(i, (k, v))| (I::from_index(i), k.as_str_ident(), v))
+    }
+
+    pub fn iter_mut(
+        &mut self,
+    ) -> impl DoubleEndedIterator<Item = (I, Ident<&str>, &mut V)> + ExactSizeIterator + '_ {
+        self.items
+            .iter_mut()
+            .enumerate()
+            .map(|(i, (k, v))| (I::from_index(i), k.as_str_ident(), v))
+    }
+}
+
+impl<I: RegistryIdx, V> Index<I> for Registry<I, V> {
+    type Output = V;
+
+    fn index(&self, index: I) -> &Self::Output {
+        self.items
+            .get_index(index.to_index())
+            .unwrap_or_else(|| panic!("out of bounds registry index of {}", index.to_index()))
+            .1
+    }
+}
+
+impl<I: RegistryIdx, V> IndexMut<I> for Registry<I, V> {
+    fn index_mut(&mut self, index: I) -> &mut Self::Output {
+        self.items
+            .get_index_mut(index.to_index())
+            .unwrap_or_else(|| panic!("out of bounds registry index of {}", index.to_index()))
+            .1
+    }
+}
+
+impl<'a, I: RegistryIdx, V> Index<Ident<&'a str>> for Registry<I, V> {
+    type Output = V;
+
+    fn index(&self, index: Ident<&'a str>) -> &Self::Output {
+        if let Some(item) = self.items.get(index.as_str()) {
+            item
+        } else {
+            panic!("missing registry item with name '{index}'")
         }
     }
 }
+
+impl<'a, I: RegistryIdx, V> IndexMut<Ident<&'a str>> for Registry<I, V> {
+    fn index_mut(&mut self, index: Ident<&'a str>) -> &mut Self::Output {
+        if let Some(item) = self.items.get_mut(index.as_str()) {
+            item
+        } else {
+            panic!("missing registry item with name '{index}'")
+        }
+    }
+}
+
+impl<I, V> Default for Registry<I, V> {
+    fn default() -> Self {
+        Self {
+            items: IndexMap::new(),
+            _marker: PhantomData,
+        }
+    }
+}
+
+pub trait RegistryIdx: Copy + Clone + PartialEq + Eq + PartialOrd + Ord + Hash + Debug {
+    const MAX: usize;
+
+    fn to_index(self) -> usize;
+    fn from_index(idx: usize) -> Self;
+}
diff --git a/crates/valence_registry/src/tags.rs b/crates/valence_registry/src/tags.rs
index b80ef0d..631ac29 100644
--- a/crates/valence_registry/src/tags.rs
+++ b/crates/valence_registry/src/tags.rs
@@ -1,5 +1,6 @@
 use std::borrow::Cow;
 
+use bevy_app::prelude::*;
 use bevy_ecs::prelude::*;
 use serde::Deserialize;
 use valence_core::ident::Ident;
@@ -8,6 +9,14 @@ use valence_core::protocol::var_int::VarInt;
 use valence_core::protocol::{packet_id, Decode, Encode, Packet};
 use valence_core::Server;
 
+use crate::RegistrySet;
+
+pub(super) fn build(app: &mut App) {
+    app.init_resource::<TagsRegistry>()
+        .add_startup_system(init_tags_registry)
+        .add_system(cache_tags_packet.in_set(RegistrySet));
+}
+
 #[derive(Clone, Debug, Encode, Decode, Packet)]
 #[packet(id = packet_id::SYNCHRONIZE_TAGS_S2C)]
 pub struct SynchronizeTagsS2c<'a> {
@@ -32,8 +41,8 @@ pub struct TagEntry {
     pub entries: Vec<VarInt>,
 }
 
-impl<'a> TagsRegistry {
-    pub(crate) fn build_synchronize_tags(&'a self) -> SynchronizeTagsS2c<'a> {
+impl TagsRegistry {
+    fn build_synchronize_tags(&self) -> SynchronizeTagsS2c {
         SynchronizeTagsS2c {
             registries: Cow::Borrowed(&self.registries),
         }
@@ -44,7 +53,7 @@ impl<'a> TagsRegistry {
     }
 }
 
-pub(crate) fn init_tags_registry(mut tags: ResMut<TagsRegistry>) {
+pub fn init_tags_registry(mut tags: ResMut<TagsRegistry>) {
     let registries =
         serde_json::from_str::<Vec<Registry>>(include_str!("../../../extracted/tags.json"))
             .expect("tags.json is invalid");
diff --git a/tools/playground/src/playground.template.rs b/tools/playground/src/playground.template.rs
index 58610c4..1282de1 100644
--- a/tools/playground/src/playground.template.rs
+++ b/tools/playground/src/playground.template.rs
@@ -22,8 +22,8 @@ pub fn build_app(app: &mut App) {
 fn setup(
     mut commands: Commands,
     server: Res<Server>,
-    biomes: Query<&Biome>,
-    dimensions: Query<&DimensionType>,
+    biomes: Res<BiomeRegistry>,
+    dimensions: Res<DimensionTypeRegistry>,
 ) {
     let mut instance = Instance::new(ident!("overworld"), &dimensions, &biomes, &server);