diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index d30e60298..b0ea5fdf6 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -61,6 +61,7 @@ dependencies {
 
     // Test
     testImplementation(libs.junit)
+    testImplementation(libs.mockito)
 
     // Annotation Processors
     compileOnly(projects.ap)
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
index bc6108abf..9df1d2189 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
@@ -76,6 +76,7 @@ import org.geysermc.geyser.erosion.UnixSocketClientListener;
 import org.geysermc.geyser.event.GeyserEventBus;
 import org.geysermc.geyser.extension.GeyserExtensionManager;
 import org.geysermc.geyser.impl.MinecraftVersionImpl;
+import org.geysermc.geyser.level.BedrockDimension;
 import org.geysermc.geyser.level.WorldManager;
 import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.network.netty.GeyserServer;
@@ -95,7 +96,6 @@ import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.util.AssetUtils;
 import org.geysermc.geyser.util.CooldownUtils;
-import org.geysermc.geyser.util.DimensionUtils;
 import org.geysermc.geyser.util.Metrics;
 import org.geysermc.geyser.util.MinecraftAuthLogger;
 import org.geysermc.geyser.util.NewsHandler;
@@ -425,7 +425,7 @@ public class GeyserImpl implements GeyserApi, EventRegistrar {
         }
 
         CooldownUtils.setDefaultShowCooldown(config.getShowCooldown());
-        DimensionUtils.changeBedrockNetherId(config.isAboveBedrockNetherBuilding()); // Apply End dimension ID workaround to Nether
+        BedrockDimension.changeBedrockNetherId(config.isAboveBedrockNetherBuilding()); // Apply End dimension ID workaround to Nether
 
         Integer bedrockThreadCount = Integer.getInteger("Geyser.BedrockNetworkThreads");
         if (bedrockThreadCount == null) {
diff --git a/core/src/main/java/org/geysermc/geyser/entity/EntityDefinition.java b/core/src/main/java/org/geysermc/geyser/entity/EntityDefinition.java
index f9b65a545..ea3950bd4 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/EntityDefinition.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/EntityDefinition.java
@@ -25,10 +25,10 @@
 
 package org.geysermc.geyser.entity;
 
-import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.EntityMetadata;
-import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.MetadataType;
-import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
 import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.BiConsumer;
 import lombok.Setter;
 import lombok.experimental.Accessors;
 import org.geysermc.geyser.GeyserImpl;
@@ -37,10 +37,10 @@ import org.geysermc.geyser.entity.properties.GeyserEntityProperties;
 import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.translator.entity.EntityMetadataTranslator;
-
-import java.util.List;
-import java.util.Locale;
-import java.util.function.BiConsumer;
+import org.geysermc.geyser.util.EnvironmentUtils;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.EntityMetadata;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.MetadataType;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
 
 /**
  * Represents data for an entity. This includes properties such as height and width, as well as the list of entity
@@ -146,8 +146,13 @@ public record EntityDefinition<T extends Entity>(EntityFactory<T> factory, Entit
             return this;
         }
 
+        /**
+         * Build the given entity. If a testing environment has been discovered the entity is not registered,
+         * otherwise it is. This is to prevent all the registries from loading, which will fail (and should
+         * not be loaded) while testing
+         */
         public EntityDefinition<T> build() {
-            return build(true);
+            return build(!EnvironmentUtils.isUnitTesting);
         }
 
         /**
diff --git a/core/src/main/java/org/geysermc/geyser/entity/EntityDefinitions.java b/core/src/main/java/org/geysermc/geyser/entity/EntityDefinitions.java
index 5932ecf41..39357eb60 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/EntityDefinitions.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/EntityDefinitions.java
@@ -25,34 +25,131 @@
 
 package org.geysermc.geyser.entity;
 
-import org.geysermc.geyser.entity.type.AbstractWindChargeEntity;
-import org.geysermc.geyser.entity.factory.EntityFactory;
-import org.geysermc.geyser.entity.type.living.monster.raid.RavagerEntity;
-import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.MetadataType;
-import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.BooleanEntityMetadata;
-import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.FloatEntityMetadata;
-import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
+import org.geysermc.geyser.entity.factory.EntityFactory;
 import org.geysermc.geyser.entity.properties.GeyserEntityProperties;
-import org.geysermc.geyser.entity.type.*;
-import org.geysermc.geyser.entity.type.living.*;
-import org.geysermc.geyser.entity.type.living.animal.*;
-import org.geysermc.geyser.entity.type.living.animal.horse.*;
+import org.geysermc.geyser.entity.type.AbstractArrowEntity;
+import org.geysermc.geyser.entity.type.AbstractWindChargeEntity;
+import org.geysermc.geyser.entity.type.AreaEffectCloudEntity;
+import org.geysermc.geyser.entity.type.ArrowEntity;
+import org.geysermc.geyser.entity.type.BoatEntity;
+import org.geysermc.geyser.entity.type.ChestBoatEntity;
+import org.geysermc.geyser.entity.type.CommandBlockMinecartEntity;
+import org.geysermc.geyser.entity.type.DisplayBaseEntity;
+import org.geysermc.geyser.entity.type.EnderCrystalEntity;
+import org.geysermc.geyser.entity.type.Entity;
+import org.geysermc.geyser.entity.type.EvokerFangsEntity;
+import org.geysermc.geyser.entity.type.ExpOrbEntity;
+import org.geysermc.geyser.entity.type.FallingBlockEntity;
+import org.geysermc.geyser.entity.type.FireballEntity;
+import org.geysermc.geyser.entity.type.FireworkEntity;
+import org.geysermc.geyser.entity.type.FishingHookEntity;
+import org.geysermc.geyser.entity.type.FurnaceMinecartEntity;
+import org.geysermc.geyser.entity.type.InteractionEntity;
+import org.geysermc.geyser.entity.type.ItemEntity;
+import org.geysermc.geyser.entity.type.ItemFrameEntity;
+import org.geysermc.geyser.entity.type.LeashKnotEntity;
+import org.geysermc.geyser.entity.type.LightningEntity;
+import org.geysermc.geyser.entity.type.LivingEntity;
+import org.geysermc.geyser.entity.type.MinecartEntity;
+import org.geysermc.geyser.entity.type.PaintingEntity;
+import org.geysermc.geyser.entity.type.SpawnerMinecartEntity;
+import org.geysermc.geyser.entity.type.TNTEntity;
+import org.geysermc.geyser.entity.type.TextDisplayEntity;
+import org.geysermc.geyser.entity.type.ThrowableEntity;
+import org.geysermc.geyser.entity.type.ThrowableItemEntity;
+import org.geysermc.geyser.entity.type.ThrownPotionEntity;
+import org.geysermc.geyser.entity.type.TridentEntity;
+import org.geysermc.geyser.entity.type.WitherSkullEntity;
+import org.geysermc.geyser.entity.type.living.AbstractFishEntity;
+import org.geysermc.geyser.entity.type.living.AgeableEntity;
+import org.geysermc.geyser.entity.type.living.AllayEntity;
+import org.geysermc.geyser.entity.type.living.ArmorStandEntity;
+import org.geysermc.geyser.entity.type.living.BatEntity;
+import org.geysermc.geyser.entity.type.living.DolphinEntity;
+import org.geysermc.geyser.entity.type.living.GlowSquidEntity;
+import org.geysermc.geyser.entity.type.living.IronGolemEntity;
+import org.geysermc.geyser.entity.type.living.MagmaCubeEntity;
+import org.geysermc.geyser.entity.type.living.MobEntity;
+import org.geysermc.geyser.entity.type.living.SlimeEntity;
+import org.geysermc.geyser.entity.type.living.SnowGolemEntity;
+import org.geysermc.geyser.entity.type.living.SquidEntity;
+import org.geysermc.geyser.entity.type.living.TadpoleEntity;
+import org.geysermc.geyser.entity.type.living.animal.ArmadilloEntity;
+import org.geysermc.geyser.entity.type.living.animal.AxolotlEntity;
+import org.geysermc.geyser.entity.type.living.animal.BeeEntity;
+import org.geysermc.geyser.entity.type.living.animal.ChickenEntity;
+import org.geysermc.geyser.entity.type.living.animal.CowEntity;
+import org.geysermc.geyser.entity.type.living.animal.FoxEntity;
+import org.geysermc.geyser.entity.type.living.animal.FrogEntity;
+import org.geysermc.geyser.entity.type.living.animal.GoatEntity;
+import org.geysermc.geyser.entity.type.living.animal.HoglinEntity;
+import org.geysermc.geyser.entity.type.living.animal.MooshroomEntity;
+import org.geysermc.geyser.entity.type.living.animal.OcelotEntity;
+import org.geysermc.geyser.entity.type.living.animal.PandaEntity;
+import org.geysermc.geyser.entity.type.living.animal.PigEntity;
+import org.geysermc.geyser.entity.type.living.animal.PolarBearEntity;
+import org.geysermc.geyser.entity.type.living.animal.PufferFishEntity;
+import org.geysermc.geyser.entity.type.living.animal.RabbitEntity;
+import org.geysermc.geyser.entity.type.living.animal.SheepEntity;
+import org.geysermc.geyser.entity.type.living.animal.SnifferEntity;
+import org.geysermc.geyser.entity.type.living.animal.StriderEntity;
+import org.geysermc.geyser.entity.type.living.animal.TropicalFishEntity;
+import org.geysermc.geyser.entity.type.living.animal.TurtleEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.AbstractHorseEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.CamelEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.ChestedHorseEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.HorseEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.LlamaEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.SkeletonHorseEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.TraderLlamaEntity;
+import org.geysermc.geyser.entity.type.living.animal.horse.ZombieHorseEntity;
 import org.geysermc.geyser.entity.type.living.animal.tameable.CatEntity;
 import org.geysermc.geyser.entity.type.living.animal.tameable.ParrotEntity;
 import org.geysermc.geyser.entity.type.living.animal.tameable.TameableEntity;
 import org.geysermc.geyser.entity.type.living.animal.tameable.WolfEntity;
 import org.geysermc.geyser.entity.type.living.merchant.AbstractMerchantEntity;
 import org.geysermc.geyser.entity.type.living.merchant.VillagerEntity;
-import org.geysermc.geyser.entity.type.living.monster.*;
+import org.geysermc.geyser.entity.type.living.monster.AbstractSkeletonEntity;
+import org.geysermc.geyser.entity.type.living.monster.BasePiglinEntity;
+import org.geysermc.geyser.entity.type.living.monster.BlazeEntity;
+import org.geysermc.geyser.entity.type.living.monster.BoggedEntity;
+import org.geysermc.geyser.entity.type.living.monster.BreezeEntity;
+import org.geysermc.geyser.entity.type.living.monster.CreeperEntity;
+import org.geysermc.geyser.entity.type.living.monster.ElderGuardianEntity;
+import org.geysermc.geyser.entity.type.living.monster.EnderDragonEntity;
+import org.geysermc.geyser.entity.type.living.monster.EnderDragonPartEntity;
+import org.geysermc.geyser.entity.type.living.monster.EndermanEntity;
+import org.geysermc.geyser.entity.type.living.monster.GhastEntity;
+import org.geysermc.geyser.entity.type.living.monster.GiantEntity;
+import org.geysermc.geyser.entity.type.living.monster.GuardianEntity;
+import org.geysermc.geyser.entity.type.living.monster.MonsterEntity;
+import org.geysermc.geyser.entity.type.living.monster.PhantomEntity;
+import org.geysermc.geyser.entity.type.living.monster.PiglinEntity;
+import org.geysermc.geyser.entity.type.living.monster.ShulkerEntity;
+import org.geysermc.geyser.entity.type.living.monster.SkeletonEntity;
+import org.geysermc.geyser.entity.type.living.monster.SpiderEntity;
+import org.geysermc.geyser.entity.type.living.monster.VexEntity;
+import org.geysermc.geyser.entity.type.living.monster.WardenEntity;
+import org.geysermc.geyser.entity.type.living.monster.WitherEntity;
+import org.geysermc.geyser.entity.type.living.monster.ZoglinEntity;
+import org.geysermc.geyser.entity.type.living.monster.ZombieEntity;
+import org.geysermc.geyser.entity.type.living.monster.ZombieVillagerEntity;
+import org.geysermc.geyser.entity.type.living.monster.ZombifiedPiglinEntity;
 import org.geysermc.geyser.entity.type.living.monster.raid.PillagerEntity;
 import org.geysermc.geyser.entity.type.living.monster.raid.RaidParticipantEntity;
+import org.geysermc.geyser.entity.type.living.monster.raid.RavagerEntity;
 import org.geysermc.geyser.entity.type.living.monster.raid.SpellcasterIllagerEntity;
 import org.geysermc.geyser.entity.type.living.monster.raid.VindicatorEntity;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.geyser.util.EnvironmentUtils;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.MetadataType;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.BooleanEntityMetadata;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.FloatEntityMetadata;
+import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
 
 public final class EntityDefinitions {
     public static final EntityDefinition<AllayEntity> ALLAY;
@@ -1025,7 +1122,10 @@ public final class EntityDefinitions {
                 .identifier("minecraft:armor_stand") // Emulated
                 .build(false); // Never sent over the network
 
-        Registries.JAVA_ENTITY_IDENTIFIERS.get().put("minecraft:marker", null); // We don't need an entity definition for this as it is never sent over the network
+        // causes the registries to load
+        if (!EnvironmentUtils.isUnitTesting) {
+            Registries.JAVA_ENTITY_IDENTIFIERS.get().put("minecraft:marker", null); // We don't need an entity definition for this as it is never sent over the network
+        }
     }
 
     public static void init() {
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java
index 08e87dc03..a016916f0 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java
@@ -25,6 +25,12 @@
 
 package org.geysermc.geyser.entity.type;
 
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
@@ -35,12 +41,18 @@ import org.cloudburstmc.math.vector.Vector3f;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityEventType;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
-import org.cloudburstmc.protocol.bedrock.packet.*;
+import org.cloudburstmc.protocol.bedrock.packet.AddEntityPacket;
+import org.cloudburstmc.protocol.bedrock.packet.EntityEventPacket;
+import org.cloudburstmc.protocol.bedrock.packet.MoveEntityAbsolutePacket;
+import org.cloudburstmc.protocol.bedrock.packet.MoveEntityDeltaPacket;
+import org.cloudburstmc.protocol.bedrock.packet.RemoveEntityPacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket;
 import org.geysermc.geyser.api.entity.type.GeyserEntity;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.entity.GeyserDirtyMetadata;
 import org.geysermc.geyser.entity.properties.GeyserEntityPropertyManager;
 import org.geysermc.geyser.item.Items;
+import org.geysermc.geyser.scoreboard.Team;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.util.EntityUtils;
@@ -55,12 +67,9 @@ import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.IntEnt
 import org.geysermc.mcprotocollib.protocol.data.game.entity.player.Hand;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
 
-import java.util.*;
-
 @Getter
 @Setter
 public class Entity implements GeyserEntity {
-
     private static final boolean PRINT_ENTITY_SPAWN_DEBUG = Boolean.parseBoolean(System.getProperty("Geyser.PrintEntitySpawnDebug", "false"));
 
     protected final GeyserSession session;
@@ -68,6 +77,12 @@ public class Entity implements GeyserEntity {
     protected int entityId;
     protected final long geyserId;
     protected UUID uuid;
+    /**
+     * Do not call this setter directly!
+     * This will bypass the scoreboard and setting the metadata
+     */
+    @Setter(AccessLevel.NONE)
+    protected String nametag = "";
 
     protected Vector3f position;
     protected Vector3f motion;
@@ -97,7 +112,7 @@ public class Entity implements GeyserEntity {
     @Setter(AccessLevel.NONE)
     private float boundingBoxWidth;
     @Setter(AccessLevel.NONE)
-    protected String nametag = "";
+    private String displayName;
     @Setter(AccessLevel.NONE)
     protected boolean silent = false;
     /* Metadata end */
@@ -126,11 +141,12 @@ public class Entity implements GeyserEntity {
 
     public Entity(GeyserSession session, int entityId, long geyserId, UUID uuid, EntityDefinition<?> definition, Vector3f position, Vector3f motion, float yaw, float pitch, float headYaw) {
         this.session = session;
+        this.definition = definition;
+        this.displayName = standardDisplayName();
 
         this.entityId = entityId;
         this.geyserId = geyserId;
         this.uuid = uuid;
-        this.definition = definition;
         this.motion = motion;
         this.yaw = yaw;
         this.pitch = pitch;
@@ -341,7 +357,7 @@ public class Entity implements GeyserEntity {
      * Sends the Bedrock metadata to the client
      */
     public void updateBedrockMetadata() {
-        if (!valid) {
+        if (!isValid()) {
             return;
         }
 
@@ -410,17 +426,84 @@ public class Entity implements GeyserEntity {
         return 300;
     }
 
+    public String teamIdentifier() {
+        // experience orbs are the only known entities that do not send an uuid (even though they do have one),
+        // but to be safe in the future it's done in the entity class itself instead of the entity specific one.
+        // All entities without an uuid cannot show up in the scoreboard!
+        return uuid != null ? uuid.toString() : null;
+    }
+
     public void setDisplayName(EntityMetadata<Optional<Component>, ?> entityMetadata) {
+        // displayName is shown when always display name is enabled. Either with or without team.
+        // That's why there are both a displayName and a nametag variable.
+        // Displayname is ignored for players, and is always their username.
         Optional<Component> name = entityMetadata.getValue();
         if (name.isPresent()) {
-            nametag = MessageTranslator.convertMessage(name.get(), session.locale());
-            dirtyMetadata.put(EntityDataTypes.NAME, nametag);
-        } else if (!nametag.isEmpty()) {
-            // Clear nametag
-            dirtyMetadata.put(EntityDataTypes.NAME, "");
+            String displayName = MessageTranslator.convertMessage(name.get(), session.locale());
+            this.displayName = displayName;
+            setNametag(displayName, true);
+            return;
         }
+
+        // if no displayName is set, use entity name (ENDER_DRAGON -> Ender Dragon)
+        // maybe we can/should use a translatable here instead?
+        this.displayName = standardDisplayName();
+        setNametag(null, true);
     }
 
+    protected String standardDisplayName() {
+        return EntityUtils.translatedEntityName(definition.entityType(), session);
+    }
+
+    protected void setNametag(@Nullable String nametag, boolean fromDisplayName) {
+        // ensure that the team format is used when nametag changes
+        if (nametag != null && fromDisplayName) {
+            var team = session.getWorldCache().getScoreboard().getTeamFor(teamIdentifier());
+            if (team != null) {
+                updateNametag(team);
+                return;
+            }
+        }
+
+        if (nametag == null) {
+            nametag = "";
+        }
+        boolean changed = !Objects.equals(this.nametag, nametag);
+        this.nametag = nametag;
+        // we only update metadata if the value has changed
+        if (!changed) {
+            return;
+        }
+
+        dirtyMetadata.put(EntityDataTypes.NAME, nametag);
+        // if nametag (player with team) is hidden for player, so should the score (belowname)
+        scoreVisibility(!nametag.isEmpty());
+    }
+
+    public void updateNametag(@Nullable Team team) {
+        // allow LivingEntity+ to have a different visibility check
+        updateNametag(team, true);
+    }
+
+    protected void updateNametag(@Nullable Team team, boolean visible) {
+        if (team != null) {
+            String newNametag;
+            // (team) visibility is LivingEntity+, team displayName is Entity+
+            if (visible) {
+                newNametag = team.displayName(getDisplayName());
+            } else {
+                // The name is not visible to the session player; clear name
+                newNametag = "";
+            }
+            setNametag(newNametag, false);
+            return;
+        }
+        // The name has reset, if it was previously something else
+        setNametag(null, false);
+    }
+
+    protected void scoreVisibility(boolean show) {}
+
     public void setDisplayNameVisible(BooleanEntityMetadata entityMetadata) {
         dirtyMetadata.put(EntityDataTypes.NAMETAG_ALWAYS_SHOW, (byte) (entityMetadata.getPrimitiveValue() ? 1 : 0));
     }
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java
index 266189e63..626ceca5c 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/LivingEntity.java
@@ -25,6 +25,11 @@
 
 package org.geysermc.geyser.entity.type;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
@@ -45,6 +50,7 @@ import org.geysermc.geyser.entity.vehicle.ClientVehicle;
 import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.item.Items;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.scoreboard.Team;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.item.ItemTranslator;
 import org.geysermc.geyser.util.AttributeUtils;
@@ -65,12 +71,9 @@ import org.geysermc.mcprotocollib.protocol.data.game.level.particle.EntityEffect
 import org.geysermc.mcprotocollib.protocol.data.game.level.particle.Particle;
 import org.geysermc.mcprotocollib.protocol.data.game.level.particle.ParticleType;
 
-import java.util.*;
-
 @Getter
 @Setter
 public class LivingEntity extends Entity {
-
     protected ItemData helmet = ItemData.AIR;
     protected ItemData chestplate = ItemData.AIR;
     protected ItemData leggings = ItemData.AIR;
@@ -150,6 +153,16 @@ public class LivingEntity extends Entity {
         dirtyMetadata.put(EntityDataTypes.STRUCTURAL_INTEGRITY, 1);
     }
 
+    @Override
+    public void updateNametag(@Nullable Team team) {
+        // if name not visible, don't mark it as visible
+        updateNametag(team, team == null || team.isVisibleFor(session.getPlayerEntity().getUsername()));
+    }
+
+    public void hideNametag() {
+        setNametag("", false);
+    }
+
     public void setLivingEntityFlags(ByteEntityMetadata entityMetadata) {
         byte xd = entityMetadata.getPrimitiveValue();
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java
index 0a108be73..fbfc5d40a 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/RabbitEntity.java
@@ -25,6 +25,7 @@
 
 package org.geysermc.geyser.entity.type.living.animal;
 
+import net.kyori.adventure.key.Key;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.cloudburstmc.math.vector.Vector3f;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
@@ -32,11 +33,13 @@ import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.cache.tags.ItemTag;
+import org.geysermc.geyser.util.EntityUtils;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.IntEntityMetadata;
 
 import java.util.UUID;
 
 public class RabbitEntity extends AnimalEntity {
+    private boolean isKillerBunny;
 
     public RabbitEntity(GeyserSession session, int entityId, long geyserId, UUID uuid, EntityDefinition<?> definition, Vector3f position, Vector3f motion, float yaw, float pitch, float headYaw) {
         super(session, entityId, geyserId, uuid, definition, position, motion, yaw, pitch, headYaw);
@@ -46,7 +49,7 @@ public class RabbitEntity extends AnimalEntity {
         int variant = entityMetadata.getPrimitiveValue();
 
         // Change the killer bunny to display as white since it only exists on Java Edition
-        boolean isKillerBunny = variant == 99;
+        isKillerBunny = variant == 99;
         if (isKillerBunny) {
             variant = 1;
         }
@@ -56,6 +59,14 @@ public class RabbitEntity extends AnimalEntity {
         dirtyMetadata.put(EntityDataTypes.VARIANT, variant);
     }
 
+    @Override
+    protected String standardDisplayName() {
+        if (isKillerBunny) {
+            return EntityUtils.translatedEntityName(Key.key("killer_bunny"), session);
+        }
+        return super.standardDisplayName();
+    }
+
     @Override
     protected float getAdultSize() {
         return 0.55f;
@@ -71,4 +82,4 @@ public class RabbitEntity extends AnimalEntity {
     protected ItemTag getFoodTag() {
         return ItemTag.RABBIT_FOOD;
     }
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/TurtleEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/TurtleEntity.java
index 16901a844..ae7f2d2bd 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/TurtleEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/TurtleEntity.java
@@ -55,6 +55,17 @@ public class TurtleEntity extends AnimalEntity {
         return ItemTag.TURTLE_FOOD;
     }
 
+    @Override
+    protected float getAdultSize() {
+        return super.getAdultSize() * 0.7f;
+    }
+
+    @Override
+    protected float getBabySize() {
+        // 0.3f is Java scale, plus Bedrock difference
+        return 0.3f * 0.5f;
+    }
+
     @Override
     public boolean canBeLeashed() {
         return false;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java
index b326f2e04..4e0de44ea 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java
@@ -25,6 +25,12 @@
 
 package org.geysermc.geyser.entity.type.player;
 
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 import lombok.Getter;
 import lombok.Setter;
 import net.kyori.adventure.text.Component;
@@ -32,19 +38,18 @@ import org.checkerframework.checker.nullness.qual.Nullable;
 import org.cloudburstmc.math.vector.Vector3f;
 import org.cloudburstmc.math.vector.Vector3i;
 import org.cloudburstmc.nbt.NbtMap;
-import org.cloudburstmc.nbt.NbtMapBuilder;
 import org.cloudburstmc.protocol.bedrock.data.Ability;
 import org.cloudburstmc.protocol.bedrock.data.AbilityLayer;
 import org.cloudburstmc.protocol.bedrock.data.GameType;
 import org.cloudburstmc.protocol.bedrock.data.PlayerPermission;
 import org.cloudburstmc.protocol.bedrock.data.command.CommandPermission;
+import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataMap;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityLinkData;
 import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData;
 import org.cloudburstmc.protocol.bedrock.packet.AddPlayerPacket;
 import org.cloudburstmc.protocol.bedrock.packet.MovePlayerPacket;
-import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket;
 import org.cloudburstmc.protocol.bedrock.packet.SetEntityLinkPacket;
 import org.cloudburstmc.protocol.bedrock.packet.UpdateAttributesPacket;
 import org.geysermc.geyser.api.entity.type.player.GeyserPlayerEntity;
@@ -53,32 +58,13 @@ import org.geysermc.geyser.entity.attribute.GeyserAttributeType;
 import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.type.LivingEntity;
 import org.geysermc.geyser.entity.type.living.animal.tameable.ParrotEntity;
-import org.geysermc.geyser.scoreboard.Objective;
-import org.geysermc.geyser.scoreboard.Score;
-import org.geysermc.geyser.scoreboard.Team;
-import org.geysermc.geyser.scoreboard.UpdateType;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.text.ChatColor;
-import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.util.ChunkUtils;
-import org.geysermc.mcprotocollib.protocol.codec.NbtComponentSerializer;
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.BlankFormat;
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat;
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.StyledFormat;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.EntityMetadata;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.Pose;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.BooleanEntityMetadata;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.ByteEntityMetadata;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.metadata.type.FloatEntityMetadata;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
-
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-import java.util.concurrent.TimeUnit;
 
 @Getter @Setter
 public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
@@ -96,6 +82,9 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
 
     private String username;
 
+    private String cachedScore = "";
+    private boolean scoreVisible = true;
+
     /**
      * The textures property from the GameProfile.
      */
@@ -123,6 +112,20 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
         this.texturesProperty = texturesProperty;
     }
 
+    /**
+     * Do not use! For testing purposes only
+     */
+    public PlayerEntity(GeyserSession session, long geyserId, UUID uuid, String username) {
+        super(session, -1, geyserId, uuid, EntityDefinitions.PLAYER, Vector3f.ZERO, Vector3f.ZERO, 0, 0, 0);
+        this.username = username;
+        this.nametag = username;
+        this.texturesProperty = null;
+
+        // clear initial metadata
+        dirtyMetadata.apply(new EntityDataMap());
+        setFlagsDirty(false);
+    }
+
     @Override
     protected void initializeMetadata() {
         super.initializeMetadata();
@@ -132,17 +135,6 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
 
     @Override
     public void spawnEntity() {
-        // Check to see if the player should have a belowname counterpart added
-        Objective objective = session.getWorldCache().getScoreboard().getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME);
-        if (objective != null) {
-            setBelowNameText(objective);
-        }
-
-        // Update in case this entity has been despawned, then respawned
-        this.nametag = this.username;
-        // The name can't be updated later (the entity metadata for it is ignored), so we need to check for this now
-        updateDisplayName(session.getWorldCache().getScoreboard().getTeamFor(username));
-
         AddPlayerPacket addPlayerPacket = new AddPlayerPacket();
         addPlayerPacket.setUuid(uuid);
         addPlayerPacket.setUsername(username);
@@ -177,6 +169,7 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
 
         // Since we re-use player entities: Clear flags, held item, etc
         this.resetMetadata();
+        this.nametag = username;
         this.hand = ItemData.AIR;
         this.offhand = ItemData.AIR;
         this.boots = ItemData.AIR;
@@ -386,38 +379,30 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
         }
     }
 
+    @Override
+    public String getDisplayName() {
+        return username;
+    }
+
     @Override
     public void setDisplayName(EntityMetadata<Optional<Component>, ?> entityMetadata) {
         // Doesn't do anything for players
     }
 
-    //todo this will become common entity logic once UUID support is implemented for them
-    public void updateDisplayName(@Nullable Team team) {
-        boolean needsUpdate;
-        if (team != null) {
-            String newDisplayName;
-            if (team.isVisibleFor(session.getPlayerEntity().getUsername())) {
-                TeamColor color = team.getColor();
-                String chatColor = MessageTranslator.toChatColor(color);
-                // We have to emulate what modern Java text already does for us and add the color to each section
-                String prefix = team.getCurrentData().getPrefix();
-                String suffix = team.getCurrentData().getSuffix();
-                newDisplayName = chatColor + prefix + chatColor + this.username + chatColor + suffix;
-            } else {
-                // The name is not visible to the session player; clear name
-                newDisplayName = "";
-            }
-            needsUpdate = !newDisplayName.equals(this.nametag);
-            this.nametag = newDisplayName;
-        } else {
-            // The name has reset, if it was previously something else
-            needsUpdate = !this.nametag.equals(this.username);
-            this.nametag = this.username;
-        }
+    @Override
+    public String teamIdentifier() {
+        return username;
+    }
 
-        if (needsUpdate) {
-            dirtyMetadata.put(EntityDataTypes.NAME, this.nametag);
+    @Override
+    protected void setNametag(@Nullable String nametag, boolean fromDisplayName) {
+        // when fromDisplayName, LivingEntity will call scoreboard code. After that
+        // setNametag is called again with fromDisplayName on false
+        if (nametag == null && !fromDisplayName) {
+            // nametag = null means reset, so reset it back to username
+            nametag = username;
         }
+        super.setNametag(nametag, fromDisplayName);
     }
 
     @Override
@@ -425,6 +410,33 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
         // Doesn't do anything for players
     }
 
+    public void setBelowNameText(String text) {
+        if (text == null) {
+            text = "";
+        }
+
+        boolean changed = !Objects.equals(cachedScore, text);
+        cachedScore = text;
+        if (isScoreVisible() && changed) {
+            dirtyMetadata.put(EntityDataTypes.SCORE, text);
+        }
+    }
+
+    @Override
+    protected void scoreVisibility(boolean show) {
+        boolean visibilityChanged = scoreVisible != show;
+        scoreVisible = show;
+        if (!visibilityChanged) {
+            return;
+        }
+        // if the player has no cachedScore, we never have to change the score.
+        // hide = set to "" (does nothing), show = change from "" (does nothing)
+        if (cachedScore.isEmpty()) {
+            return;
+        }
+        dirtyMetadata.put(EntityDataTypes.SCORE, show ? cachedScore : "");
+    }
+
     @Override
     protected void setDimensions(Pose pose) {
         float height;
@@ -451,64 +463,6 @@ public class PlayerEntity extends LivingEntity implements GeyserPlayerEntity {
         setBoundingBoxHeight(height);
     }
 
-    public void setBelowNameText(Objective objective) {
-        if (objective != null && objective.getUpdateType() != UpdateType.REMOVE) {
-            Score score = objective.getScores().get(username);
-            String numberString;
-            NumberFormat numberFormat;
-            int amount;
-            if (score != null) {
-                amount = score.getScore();
-                numberFormat = score.getNumberFormat();
-                if (numberFormat == null) {
-                    numberFormat = objective.getNumberFormat();
-                }
-            } else {
-                amount = 0;
-                numberFormat = objective.getNumberFormat();
-            }
-
-            if (numberFormat instanceof BlankFormat) {
-                numberString = "";
-            } else if (numberFormat instanceof FixedFormat fixedFormat) {
-                numberString = MessageTranslator.convertMessage(fixedFormat.getValue());
-            } else if (numberFormat instanceof StyledFormat styledFormat) {
-                NbtMapBuilder styledAmount = styledFormat.getStyle().toBuilder();
-                styledAmount.putString("text", String.valueOf(amount));
-
-                numberString = MessageTranslator.convertJsonMessage(
-                        NbtComponentSerializer.tagComponentToJson(styledAmount.build()).toString(), session.locale());
-            } else {
-                numberString = String.valueOf(amount);
-            }
-
-            String displayString = numberString + " " + ChatColor.RESET + objective.getDisplayName();
-
-            if (valid) {
-                // Already spawned - we still need to run the rest of this code because the spawn packet will be
-                // providing the information
-                SetEntityDataPacket packet = new SetEntityDataPacket();
-                packet.setRuntimeEntityId(geyserId);
-                packet.getMetadata().put(EntityDataTypes.SCORE, displayString);
-                session.sendUpstreamPacket(packet);
-            } else {
-                // Not spawned yet, store score value in dirtyMetadata to be picked up by #spawnEntity
-                dirtyMetadata.put(EntityDataTypes.SCORE, displayString);
-            }
-        } else {
-            if (valid) {
-                SetEntityDataPacket packet = new SetEntityDataPacket();
-                packet.setRuntimeEntityId(geyserId);
-                packet.getMetadata().put(EntityDataTypes.SCORE, "");
-                session.sendUpstreamPacket(packet);
-            } else {
-                // Not spawned yet, store score value in dirtyMetadata to be picked up by #spawnEntity
-                dirtyMetadata.put(EntityDataTypes.SCORE, "");
-            }
-        }
-
-    }
-
     /**
      * @return the UUID that should be used when dealing with Bedrock's tab list.
      */
diff --git a/core/src/main/java/org/geysermc/geyser/extension/command/GeyserExtensionCommand.java b/core/src/main/java/org/geysermc/geyser/extension/command/GeyserExtensionCommand.java
index 0b22a8b8e..0dc49f5c1 100644
--- a/core/src/main/java/org/geysermc/geyser/extension/command/GeyserExtensionCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/extension/command/GeyserExtensionCommand.java
@@ -38,6 +38,7 @@ import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.incendo.cloud.CommandManager;
 import org.incendo.cloud.context.CommandContext;
+import org.incendo.cloud.description.CommandDescription;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -193,11 +194,17 @@ public abstract class GeyserExtensionCommand extends GeyserCommand {
                         .handler(this::execute));
                 }
 
+                @Override
+                protected org.incendo.cloud.Command.Builder.Applicable<GeyserCommandSource> meta() {
+                    // We don't want to localize the extension command description
+                    return builder -> builder.commandDescription(CommandDescription.commandDescription(description));
+                }
+
                 @SuppressWarnings("unchecked")
                 @Override
                 public void execute(CommandContext<GeyserCommandSource> context) {
                     GeyserCommandSource source = context.sender();
-                    String[] args = context.getOrDefault("args", "").split(" ");
+                    String[] args = context.getOrDefault("args", " ").split(" ");
 
                     if (sourceType.isInstance(source)) {
                         executor.execute((T) source, this, args);
diff --git a/core/src/main/java/org/geysermc/geyser/level/BedrockDimension.java b/core/src/main/java/org/geysermc/geyser/level/BedrockDimension.java
index 250c0f7a4..d62a17232 100644
--- a/core/src/main/java/org/geysermc/geyser/level/BedrockDimension.java
+++ b/core/src/main/java/org/geysermc/geyser/level/BedrockDimension.java
@@ -25,17 +25,84 @@
 
 package org.geysermc.geyser.level;
 
+import lombok.ToString;
+
 /**
  * A data structure to represent what Bedrock believes are the height requirements for a specific dimension.
  * As of 1.18.30, biome count is representative of the height of the world, and out-of-bounds chunks can crash
  * the client.
- *
- * @param minY The minimum height Bedrock Edition will accept.
- * @param height The maximum chunk height Bedrock Edition will accept, from the lowest point to the highest.
- * @param doUpperHeightWarn whether to warn in the console if the Java dimension height exceeds Bedrock's.
  */
-public record BedrockDimension(int minY, int height, boolean doUpperHeightWarn) {
-    public static final BedrockDimension OVERWORLD = new BedrockDimension(-64, 384, true);
-    public static final BedrockDimension THE_NETHER = new BedrockDimension(0, 128, false);
-    public static final BedrockDimension THE_END = new BedrockDimension(0, 256, true);
+@ToString
+public class BedrockDimension {
+
+    public static final int OVERWORLD_ID = 0;
+    public static final int DEFAULT_NETHER_ID = 1;
+    public static final int END_ID = 2;
+
+    // Changes if the above-bedrock Nether building workaround is applied
+    public static int BEDROCK_NETHER_ID = DEFAULT_NETHER_ID;
+
+    public static final BedrockDimension OVERWORLD = new BedrockDimension(-64, 384, true, OVERWORLD_ID);
+    public static final BedrockDimension THE_NETHER = new BedrockDimension(0, 128, false, -1) {
+        @Override
+        public int bedrockId() {
+            return BEDROCK_NETHER_ID;
+        }
+    };
+    public static final BedrockDimension THE_END = new BedrockDimension(0, 256, true, END_ID);
+    public static final String NETHER_IDENTIFIER = "minecraft:the_nether";
+
+    private final int minY;
+    private final int height;
+    private final boolean doUpperHeightWarn;
+    private final int bedrockId;
+
+    /**
+     * @param minY The minimum height Bedrock Edition will accept.
+     * @param height The maximum chunk height Bedrock Edition will accept, from the lowest point to the highest.
+     * @param doUpperHeightWarn whether to warn in the console if the Java dimension height exceeds Bedrock's.
+     * @param bedrockId the Bedrock dimension ID of this dimension.
+     */
+    public BedrockDimension(int minY, int height, boolean doUpperHeightWarn, int bedrockId) {
+        this.minY = minY;
+        this.height = height;
+        this.doUpperHeightWarn = doUpperHeightWarn;
+        this.bedrockId = bedrockId;
+    }
+
+    /**
+     * The Nether dimension in Bedrock does not permit building above Y128 - the Bedrock above the dimension.
+     * This workaround sets the Nether as the End dimension to ignore this limit.
+     *
+     * @param isAboveNetherBedrockBuilding true if we should apply The End workaround
+     */
+    public static void changeBedrockNetherId(boolean isAboveNetherBedrockBuilding) {
+        // Change dimension ID to the End to allow for building above Bedrock
+        BEDROCK_NETHER_ID = isAboveNetherBedrockBuilding ? END_ID : DEFAULT_NETHER_ID;
+    }
+
+    public static boolean isCustomBedrockNetherId() {
+        return BEDROCK_NETHER_ID == END_ID;
+    }
+
+    public int maxY() {
+        return minY + height;
+    }
+
+    public int minY() {
+        return minY;
+    }
+
+    public int height() {
+        return height;
+    }
+
+    public boolean doUpperHeightWarn() {
+        return doUpperHeightWarn;
+    }
+
+    public int bedrockId() {
+        return bedrockId;
+    }
+
 }
diff --git a/core/src/main/java/org/geysermc/geyser/level/JavaDimension.java b/core/src/main/java/org/geysermc/geyser/level/JavaDimension.java
index 50589851b..c4592517c 100644
--- a/core/src/main/java/org/geysermc/geyser/level/JavaDimension.java
+++ b/core/src/main/java/org/geysermc/geyser/level/JavaDimension.java
@@ -63,12 +63,19 @@ public record JavaDimension(int minY, int maxY, boolean piglinSafe, boolean ultr
         if ("minecraft".equals(id.namespace())) {
             String identifier = id.asString();
             bedrockId = DimensionUtils.javaToBedrock(identifier);
-            isNetherLike = DimensionUtils.NETHER_IDENTIFIER.equals(identifier);
+            isNetherLike = BedrockDimension.NETHER_IDENTIFIER.equals(identifier);
         } else {
             // Effects should give is a clue on how this (custom) dimension is supposed to look like
             String effects = dimension.getString("effects");
             bedrockId = DimensionUtils.javaToBedrock(effects);
-            isNetherLike = DimensionUtils.NETHER_IDENTIFIER.equals(effects);
+            isNetherLike = BedrockDimension.NETHER_IDENTIFIER.equals(effects);
+        }
+
+        if (minY % 16 != 0) {
+            throw new RuntimeException("Minimum Y must be a multiple of 16!");
+        }
+        if (maxY % 16 != 0) {
+            throw new RuntimeException("Maximum Y must be a multiple of 16!");
         }
 
         return new JavaDimension(minY, maxY, piglinSafe, ultrawarm, coordinateScale, bedrockId, isNetherLike);
diff --git a/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java b/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java
index d3abf934f..c188e92bb 100644
--- a/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java
+++ b/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java
@@ -51,7 +51,7 @@ public final class GameProtocol {
      * release of the game that Geyser supports.
      */
     public static final BedrockCodec DEFAULT_BEDROCK_CODEC = CodecProcessor.processCodec(Bedrock_v729.CODEC.toBuilder()
-        .minecraftVersion("1.21.30")
+        .minecraftVersion("1.21.31")
         .build());
 
     /**
@@ -78,7 +78,9 @@ public final class GameProtocol {
         SUPPORTED_BEDROCK_CODECS.add(CodecProcessor.processCodec(Bedrock_v712.CODEC.toBuilder()
             .minecraftVersion("1.21.20 - 1.21.23")
             .build()));
-        SUPPORTED_BEDROCK_CODECS.add(DEFAULT_BEDROCK_CODEC);
+        SUPPORTED_BEDROCK_CODECS.add(DEFAULT_BEDROCK_CODEC.toBuilder()
+            .minecraftVersion("1.21.30/1.21.31")
+            .build());
     }
 
     /**
diff --git a/core/src/main/java/org/geysermc/geyser/network/netty/LocalSession.java b/core/src/main/java/org/geysermc/geyser/network/netty/LocalSession.java
index 958e88288..739c1c25e 100644
--- a/core/src/main/java/org/geysermc/geyser/network/netty/LocalSession.java
+++ b/core/src/main/java/org/geysermc/geyser/network/netty/LocalSession.java
@@ -27,15 +27,30 @@ package org.geysermc.geyser.network.netty;
 
 import io.netty.bootstrap.Bootstrap;
 import io.netty.buffer.ByteBufAllocator;
-import io.netty.channel.*;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.DefaultEventLoopGroup;
 import io.netty.channel.unix.PreferredDirectByteBufAllocator;
-import io.netty.handler.codec.haproxy.*;
+import io.netty.handler.codec.haproxy.HAProxyCommand;
+import io.netty.handler.codec.haproxy.HAProxyMessage;
+import io.netty.handler.codec.haproxy.HAProxyMessageEncoder;
+import io.netty.handler.codec.haproxy.HAProxyProtocolVersion;
+import io.netty.handler.codec.haproxy.HAProxyProxiedProtocol;
+import io.netty.handler.timeout.ReadTimeoutHandler;
+import io.netty.handler.timeout.WriteTimeoutHandler;
 import io.netty.util.concurrent.DefaultThreadFactory;
 import org.checkerframework.checker.nullness.qual.NonNull;
 import org.geysermc.mcprotocollib.network.BuiltinFlags;
 import org.geysermc.mcprotocollib.network.codec.PacketCodecHelper;
 import org.geysermc.mcprotocollib.network.packet.PacketProtocol;
+import org.geysermc.mcprotocollib.network.tcp.FlushHandler;
+import org.geysermc.mcprotocollib.network.tcp.TcpFlowControlHandler;
 import org.geysermc.mcprotocollib.network.tcp.TcpPacketCodec;
+import org.geysermc.mcprotocollib.network.tcp.TcpPacketCompression;
+import org.geysermc.mcprotocollib.network.tcp.TcpPacketEncryptor;
 import org.geysermc.mcprotocollib.network.tcp.TcpPacketSizer;
 import org.geysermc.mcprotocollib.network.tcp.TcpSession;
 import org.geysermc.mcprotocollib.protocol.codec.MinecraftCodecHelper;
@@ -43,6 +58,7 @@ import org.geysermc.mcprotocollib.protocol.codec.MinecraftCodecHelper;
 import java.net.Inet4Address;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -72,44 +88,53 @@ public final class LocalSession extends TcpSession {
         if (DEFAULT_EVENT_LOOP_GROUP == null) {
             DEFAULT_EVENT_LOOP_GROUP = new DefaultEventLoopGroup(new DefaultThreadFactory(this.getClass(), true));
             Runtime.getRuntime().addShutdownHook(new Thread(
-                    () -> DEFAULT_EVENT_LOOP_GROUP.shutdownGracefully(100, 500, TimeUnit.MILLISECONDS)));
+                () -> DEFAULT_EVENT_LOOP_GROUP.shutdownGracefully(100, 500, TimeUnit.MILLISECONDS)));
         }
 
-        try {
-            final Bootstrap bootstrap = new Bootstrap();
-            bootstrap.channel(LocalChannelWithRemoteAddress.class);
-            bootstrap.handler(new ChannelInitializer<LocalChannelWithRemoteAddress>() {
-                @Override
-                public void initChannel(@NonNull LocalChannelWithRemoteAddress channel) {
-                    channel.spoofedRemoteAddress(new InetSocketAddress(clientIp, 0));
-                    PacketProtocol protocol = getPacketProtocol();
-                    protocol.newClientSession(LocalSession.this, transferring);
+        final Bootstrap bootstrap = new Bootstrap();
+        bootstrap.channel(LocalChannelWithRemoteAddress.class);
+        bootstrap.handler(new ChannelInitializer<LocalChannelWithRemoteAddress>() {
+            @Override
+            public void initChannel(@NonNull LocalChannelWithRemoteAddress channel) {
+                channel.spoofedRemoteAddress(new InetSocketAddress(clientIp, 0));
+                PacketProtocol protocol = getPacketProtocol();
+                protocol.newClientSession(LocalSession.this, transferring);
 
-                    refreshReadTimeoutHandler(channel);
-                    refreshWriteTimeoutHandler(channel);
+                ChannelPipeline pipeline = channel.pipeline();
 
-                    ChannelPipeline pipeline = channel.pipeline();
-                    pipeline.addLast("sizer", new TcpPacketSizer(LocalSession.this, protocol.getPacketHeader().getLengthSize()));
-                    pipeline.addLast("codec", new TcpPacketCodec(LocalSession.this, true));
-                    pipeline.addLast("manager", LocalSession.this);
+                addHAProxySupport(pipeline);
 
-                    addHAProxySupport(pipeline);
-                }
-            }).group(DEFAULT_EVENT_LOOP_GROUP).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, getConnectTimeout() * 1000);
+                pipeline.addLast("read-timeout", new ReadTimeoutHandler(getFlag(BuiltinFlags.READ_TIMEOUT, 30)));
+                pipeline.addLast("write-timeout", new WriteTimeoutHandler(getFlag(BuiltinFlags.WRITE_TIMEOUT, 0)));
 
-            if (PREFERRED_DIRECT_BYTE_BUF_ALLOCATOR != null) {
-                bootstrap.option(ChannelOption.ALLOCATOR, PREFERRED_DIRECT_BYTE_BUF_ALLOCATOR);
+                pipeline.addLast("encryption", new TcpPacketEncryptor());
+                pipeline.addLast("sizer", new TcpPacketSizer(protocol.getPacketHeader(), getCodecHelper()));
+                pipeline.addLast("compression", new TcpPacketCompression(getCodecHelper()));
+
+                pipeline.addLast("flow-control", new TcpFlowControlHandler());
+                pipeline.addLast("codec", new TcpPacketCodec(LocalSession.this, true));
+                pipeline.addLast("flush-handler", new FlushHandler());
+                pipeline.addLast("manager", LocalSession.this);
+            }
+        }).group(DEFAULT_EVENT_LOOP_GROUP).option(ChannelOption.CONNECT_TIMEOUT_MILLIS, getFlag(BuiltinFlags.CLIENT_CONNECT_TIMEOUT, 30) * 1000);
+
+        if (PREFERRED_DIRECT_BYTE_BUF_ALLOCATOR != null) {
+            bootstrap.option(ChannelOption.ALLOCATOR, PREFERRED_DIRECT_BYTE_BUF_ALLOCATOR);
+        }
+
+        bootstrap.remoteAddress(targetAddress);
+
+        CompletableFuture<Void> handleFuture = new CompletableFuture<>();
+        bootstrap.connect().addListener((futureListener) -> {
+            if (!futureListener.isSuccess()) {
+                exceptionCaught(null, futureListener.cause());
             }
 
-            bootstrap.remoteAddress(targetAddress);
+            handleFuture.complete(null);
+        });
 
-            bootstrap.connect().addListener((future) -> {
-                if (!future.isSuccess()) {
-                    exceptionCaught(null, future.cause());
-                }
-            });
-        } catch (Throwable t) {
-            exceptionCaught(null, t);
+        if (wait) {
+            handleFuture.join();
         }
     }
 
@@ -121,7 +146,7 @@ public final class LocalSession extends TcpSession {
     // TODO duplicate code
     private void addHAProxySupport(ChannelPipeline pipeline) {
         InetSocketAddress clientAddress = getFlag(BuiltinFlags.CLIENT_PROXIED_ADDRESS);
-        if (getFlag(BuiltinFlags.ENABLE_CLIENT_PROXY_PROTOCOL, false) && clientAddress != null) {
+        if (clientAddress != null) {
             pipeline.addFirst("proxy-protocol-packet-sender", new ChannelInboundHandlerAdapter() {
                 @Override
                 public void channelActive(@NonNull ChannelHandlerContext ctx) throws Exception {
@@ -133,9 +158,9 @@ public final class LocalSession extends TcpSession {
                         remoteAddress = new InetSocketAddress(host, port);
                     }
                     ctx.channel().writeAndFlush(new HAProxyMessage(
-                            HAProxyProtocolVersion.V2, HAProxyCommand.PROXY, proxiedProtocol,
-                            clientAddress.getAddress().getHostAddress(), remoteAddress.getAddress().getHostAddress(),
-                            clientAddress.getPort(), remoteAddress.getPort()
+                        HAProxyProtocolVersion.V2, HAProxyCommand.PROXY, proxiedProtocol,
+                        clientAddress.getAddress().getHostAddress(), remoteAddress.getAddress().getHostAddress(),
+                        clientAddress.getPort(), remoteAddress.getPort()
                     ));
                     ctx.pipeline().remove(this);
                     ctx.pipeline().remove("proxy-protocol-encoder");
@@ -144,7 +169,7 @@ public final class LocalSession extends TcpSession {
             });
             pipeline.addFirst("proxy-protocol-encoder", HAProxyMessageEncoder.INSTANCE);
         }
-    }
+        }
 
     /**
      * Should only be called when direct ByteBufs should be preferred. At this moment, this should only be called on BungeeCord.
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java
index 6c1389ef5..f3b7f20d2 100644
--- a/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Objective.java
@@ -25,185 +25,100 @@
 
 package org.geysermc.geyser.scoreboard;
 
-import lombok.Getter;
-import lombok.Setter;
-import net.kyori.adventure.text.Component;
-import org.checkerframework.checker.nullness.qual.Nullable;
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
-
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
+import lombok.Getter;
+import net.kyori.adventure.text.Component;
+import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot;
+import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
 
 @Getter
 public final class Objective {
     private final Scoreboard scoreboard;
-    private final long id;
-    private boolean active = true;
+    private final List<DisplaySlot> activeSlots = new ArrayList<>();
 
-    @Setter
-    private UpdateType updateType = UpdateType.ADD;
+    private final String objectiveName;
+    private final Map<String, ScoreReference> scores = new ConcurrentHashMap<>();
 
-    private String objectiveName;
-    private ScoreboardPosition displaySlot;
-    private String displaySlotName;
-    private String displayName = "unknown";
+    private String displayName;
     private NumberFormat numberFormat;
-    private int type = 0; // 0 = integer, 1 = heart
+    private ScoreType type;
 
-    private Map<String, Score> scores = new ConcurrentHashMap<>();
-
-    private Objective(Scoreboard scoreboard) {
-        this.id = scoreboard.getNextId().getAndIncrement();
-        this.scoreboard = scoreboard;
-    }
-
-    /**
-     * /!\ This method is made for temporary objectives until the real objective is received
-     *
-     * @param scoreboard    the scoreboard
-     * @param objectiveName the name of the objective
-     */
     public Objective(Scoreboard scoreboard, String objectiveName) {
-        this(scoreboard);
+        this.scoreboard = scoreboard;
         this.objectiveName = objectiveName;
-        this.active = false;
-    }
-
-    public Objective(Scoreboard scoreboard, String objectiveName, ScoreboardPosition displaySlot, String displayName, int type) {
-        this(scoreboard);
-        this.objectiveName = objectiveName;
-        this.displaySlot = displaySlot;
-        this.displaySlotName = translateDisplaySlot(displaySlot);
-        this.displayName = displayName;
-        this.type = type;
-    }
-
-    private static String translateDisplaySlot(ScoreboardPosition displaySlot) {
-        return switch (displaySlot) {
-            case BELOW_NAME -> "belowname";
-            case PLAYER_LIST -> "list";
-            default -> "sidebar";
-        };
     }
 
     public void registerScore(String id, int score, Component displayName, NumberFormat numberFormat) {
-        if (!scores.containsKey(id)) {
-            long scoreId = scoreboard.getNextId().getAndIncrement();
-            Score scoreObject = new Score(scoreId, id)
-                    .setScore(score)
-                    .setTeam(scoreboard.getTeamFor(id))
-                    .setDisplayName(displayName)
-                    .setNumberFormat(numberFormat)
-                    .setUpdateType(UpdateType.ADD);
-            scores.put(id, scoreObject);
+        if (scores.containsKey(id)) {
+            return;
+        }
+        var reference = new ScoreReference(scoreboard, id, score, displayName, numberFormat);
+        scores.put(id, reference);
+
+        for (var slot : activeSlots) {
+            slot.addScore(reference);
         }
     }
 
     public void setScore(String id, int score, Component displayName, NumberFormat numberFormat) {
-        Score stored = scores.get(id);
+        ScoreReference stored = scores.get(id);
         if (stored != null) {
-            stored.setScore(score)
-                    .setDisplayName(displayName)
-                    .setNumberFormat(numberFormat)
-                    .setUpdateType(UpdateType.UPDATE);
+            stored.updateProperties(scoreboard, score, displayName, numberFormat);
             return;
         }
         registerScore(id, score, displayName, numberFormat);
     }
 
     public void removeScore(String id) {
-        Score stored = scores.get(id);
+        ScoreReference stored = scores.remove(id);
         if (stored != null) {
-            stored.setUpdateType(UpdateType.REMOVE);
+            stored.markDeleted();
         }
     }
 
-    /**
-     * Used internally to remove a score from the score map
-     */
-    public void removeScore0(String id) {
-        scores.remove(id);
-    }
+    public void updateProperties(Component displayNameComponent, ScoreType type, NumberFormat format) {
+        String displayName = MessageTranslator.convertMessageRaw(displayNameComponent, scoreboard.session().locale());
+        boolean changed = !Objects.equals(this.displayName, displayName) || this.type != type;
 
-    public Objective setDisplayName(String displayName) {
         this.displayName = displayName;
-        if (updateType == UpdateType.NOTHING) {
-            updateType = UpdateType.UPDATE;
-        }
-        return this;
-    }
+        this.type = type;
 
-    public Objective setNumberFormat(NumberFormat numberFormat) {
-        if (Objects.equals(this.numberFormat, numberFormat)) {
-            return this;
-        }
-
-        this.numberFormat = numberFormat;
-        if (updateType == UpdateType.NOTHING) {
-            updateType = UpdateType.UPDATE;
-        }
-
-        // Update the number format for scores that are following this objective's number format
-        for (Score score : scores.values()) {
-            if (score.getNumberFormat() == null) {
-                score.setUpdateType(UpdateType.UPDATE);
+        if (!Objects.equals(this.numberFormat, format)) {
+            this.numberFormat = format;
+            // update the number format for scores that are following this objective's number format,
+            // but only if the objective itself doesn't need to be updated.
+            // When the objective itself has to update all scores are updated anyway
+            if (!changed) {
+                for (ScoreReference score : scores.values()) {
+                    if (score.numberFormat() == null) {
+                        score.markChanged();
+                    }
+                }
             }
         }
 
-        return this;
-    }
-
-    public Objective setType(int type) {
-        this.type = type;
-        if (updateType == UpdateType.NOTHING) {
-            updateType = UpdateType.UPDATE;
-        }
-        return this;
-    }
-
-    public void setActive(ScoreboardPosition displaySlot) {
-        if (!active) {
-            active = true;
-            this.displaySlot = displaySlot;
-            displaySlotName = translateDisplaySlot(displaySlot);
+        if (changed) {
+            for (DisplaySlot slot : activeSlots) {
+                slot.markNeedsUpdate();
+            }
         }
     }
 
-    /**
-     * The objective will be removed on the next update
-     */
-    public void pendingRemove() {
-        updateType = UpdateType.REMOVE;
+    public boolean hasDisplaySlot() {
+        return !activeSlots.isEmpty();
     }
 
-    public @Nullable TeamColor getTeamColor() {
-        return switch (displaySlot) {
-            case SIDEBAR_TEAM_RED -> TeamColor.RED;
-            case SIDEBAR_TEAM_AQUA -> TeamColor.AQUA;
-            case SIDEBAR_TEAM_BLUE -> TeamColor.BLUE;
-            case SIDEBAR_TEAM_GOLD -> TeamColor.GOLD;
-            case SIDEBAR_TEAM_GRAY -> TeamColor.GRAY;
-            case SIDEBAR_TEAM_BLACK -> TeamColor.BLACK;
-            case SIDEBAR_TEAM_GREEN -> TeamColor.GREEN;
-            case SIDEBAR_TEAM_WHITE -> TeamColor.WHITE;
-            case SIDEBAR_TEAM_YELLOW -> TeamColor.YELLOW;
-            case SIDEBAR_TEAM_DARK_RED -> TeamColor.DARK_RED;
-            case SIDEBAR_TEAM_DARK_AQUA -> TeamColor.DARK_AQUA;
-            case SIDEBAR_TEAM_DARK_BLUE -> TeamColor.DARK_BLUE;
-            case SIDEBAR_TEAM_DARK_GRAY -> TeamColor.DARK_GRAY;
-            case SIDEBAR_TEAM_DARK_GREEN -> TeamColor.DARK_GREEN;
-            case SIDEBAR_TEAM_DARK_PURPLE -> TeamColor.DARK_PURPLE;
-            case SIDEBAR_TEAM_LIGHT_PURPLE -> TeamColor.LIGHT_PURPLE;
-            default -> null;
-        };
+    public void addDisplaySlot(DisplaySlot slot) {
+        activeSlots.add(slot);
     }
 
-    public void removed() {
-        active = false;
-        updateType = UpdateType.REMOVE;
-        scores = null;
+    public void removeDisplaySlot(DisplaySlot slot) {
+        activeSlots.remove(slot);
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Score.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Score.java
deleted file mode 100644
index 9a26b7f77..000000000
--- a/core/src/main/java/org/geysermc/geyser/scoreboard/Score.java
+++ /dev/null
@@ -1,199 +0,0 @@
-/*
- * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- *
- * @author GeyserMC
- * @link https://github.com/GeyserMC/Geyser
- */
-
-package org.geysermc.geyser.scoreboard;
-
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat;
-import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
-import net.kyori.adventure.text.Component;
-import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
-import lombok.Getter;
-import lombok.experimental.Accessors;
-import org.geysermc.geyser.text.ChatColor;
-import org.geysermc.geyser.translator.text.MessageTranslator;
-
-import java.util.Objects;
-
-@Getter
-@Accessors(chain = true)
-public final class Score {
-    private final long id;
-    private final String name;
-    private ScoreInfo cachedInfo;
-
-    /**
-     * Changes that have been made since the last cached data.
-     */
-    private final Score.ScoreData currentData;
-    /**
-     * The data that is currently displayed to the Bedrock client.
-     */
-    private Score.ScoreData cachedData;
-
-    public Score(long id, String name) {
-        this.id = id;
-        this.name = name;
-        this.currentData = new ScoreData();
-    }
-
-    public String getDisplayName() {
-        String displayName = cachedData.displayName;
-        if (displayName != null) {
-            return displayName;
-        }
-        Team team = cachedData.team;
-        if (team != null) {
-            return team.getDisplayName(name);
-        }
-        return name;
-    }
-
-    public int getScore() {
-        return currentData.getScore();
-    }
-
-    public Score setScore(int score) {
-        currentData.score = score;
-        return this;
-    }
-
-    public Team getTeam() {
-        return currentData.team;
-    }
-
-    public Score setTeam(Team team) {
-        if (currentData.team != null && team != null) {
-            if (!currentData.team.equals(team)) {
-                currentData.team = team;
-                setUpdateType(UpdateType.UPDATE);
-            }
-            return this;
-        }
-        // simplified from (this.team != null && team == null) || (this.team == null && team != null)
-        if (currentData.team != null || team != null) {
-            currentData.team = team;
-            setUpdateType(UpdateType.UPDATE);
-        }
-        return this;
-    }
-
-    public Score setDisplayName(Component displayName) {
-        if (currentData.displayName != null && displayName != null) {
-            String convertedDisplayName = MessageTranslator.convertMessage(displayName);
-            if (!currentData.displayName.equals(convertedDisplayName)) {
-                currentData.displayName = convertedDisplayName;
-                setUpdateType(UpdateType.UPDATE);
-            }
-            return this;
-        }
-        // simplified from (this.displayName != null && displayName == null) || (this.displayName == null && displayName != null)
-        if (currentData.displayName != null || displayName != null) {
-            currentData.displayName = MessageTranslator.convertMessage(displayName);
-            setUpdateType(UpdateType.UPDATE);
-        }
-        return this;
-    }
-
-    public NumberFormat getNumberFormat() {
-        return currentData.numberFormat;
-    }
-
-    public Score setNumberFormat(NumberFormat numberFormat) {
-        if (!Objects.equals(currentData.numberFormat, numberFormat)) {
-            currentData.numberFormat = numberFormat;
-            setUpdateType(UpdateType.UPDATE);
-        }
-        return this;
-    }
-
-    public UpdateType getUpdateType() {
-        return currentData.updateType;
-    }
-
-    public Score setUpdateType(UpdateType updateType) {
-        if (updateType != UpdateType.NOTHING) {
-            currentData.changed = true;
-        }
-        currentData.updateType = updateType;
-        return this;
-    }
-
-    public boolean shouldUpdate() {
-        return cachedData == null || currentData.changed ||
-                (currentData.team != null && currentData.team.shouldUpdate());
-    }
-
-    public void update(Objective objective) {
-        if (cachedData == null) {
-            cachedData = new ScoreData();
-            cachedData.updateType = UpdateType.ADD;
-            if (currentData.updateType == UpdateType.REMOVE) {
-                cachedData.updateType = UpdateType.REMOVE;
-            }
-        } else {
-            cachedData.updateType = currentData.updateType;
-        }
-
-        currentData.changed = false;
-        cachedData.team = currentData.team;
-        cachedData.score = currentData.score;
-        cachedData.displayName = currentData.displayName;
-        cachedData.numberFormat = currentData.numberFormat;
-
-        String name = this.name;
-        if (cachedData.displayName != null) {
-            name = cachedData.displayName;
-        } else if (cachedData.team != null) {
-            cachedData.team.prepareUpdate();
-            name = cachedData.team.getDisplayName(name);
-        }
-
-        NumberFormat numberFormat = cachedData.numberFormat;
-        if (numberFormat == null) {
-            numberFormat = objective.getNumberFormat();
-        }
-        if (numberFormat instanceof FixedFormat fixedFormat) {
-            name += " " + ChatColor.RESET + MessageTranslator.convertMessage(fixedFormat.getValue());
-        }
-
-        cachedInfo = new ScoreInfo(id, objective.getObjectiveName(), cachedData.score, name);
-    }
-
-    @Getter
-    public static final class ScoreData {
-        private UpdateType updateType;
-        private boolean changed;
-
-        private Team team;
-        private int score;
-
-        private String displayName;
-        private NumberFormat numberFormat;
-
-        private ScoreData() {
-            updateType = UpdateType.ADD;
-        }
-    }
-}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java
new file mode 100644
index 000000000..c26a59899
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreReference.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard;
+
+import java.util.Objects;
+import net.kyori.adventure.text.Component;
+import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
+
+public final class ScoreReference {
+    public static final long LAST_UPDATE_DEFAULT = -1;
+    private static final long LAST_UPDATE_REMOVE = -2;
+
+    private final String name;
+    private final boolean hidden;
+
+    private String displayName;
+    private int score;
+    private NumberFormat numberFormat;
+
+    private long lastUpdate;
+
+    public ScoreReference(
+        Scoreboard scoreboard, String name, int score, Component displayName, NumberFormat format) {
+        this.name = name;
+        // hidden is a sidebar exclusive feature
+        this.hidden = name.startsWith("#");
+
+        updateProperties(scoreboard, score, displayName, format);
+        this.lastUpdate = LAST_UPDATE_DEFAULT;
+    }
+
+    public String name() {
+        return name;
+    }
+
+    public boolean hidden() {
+        return hidden;
+    }
+
+    public String displayName() {
+        return displayName;
+    }
+
+    public void displayName(Component displayName, Scoreboard scoreboard) {
+        if (this.displayName != null && displayName != null) {
+            String convertedDisplayName = MessageTranslator.convertMessage(displayName, scoreboard.session().locale());
+            if (!this.displayName.equals(convertedDisplayName)) {
+                this.displayName = convertedDisplayName;
+                markChanged();
+            }
+            return;
+        }
+        // simplified from (this.displayName != null && displayName == null) || (this.displayName == null && displayName != null)
+        if (this.displayName != null || displayName != null) {
+            this.displayName = MessageTranslator.convertMessage(displayName, scoreboard.session().locale());
+            markChanged();
+        }
+    }
+
+    public int score() {
+        return score;
+    }
+
+    private void score(int score) {
+        boolean changed = this.score != score;
+        this.score = score;
+        if (changed) {
+            markChanged();
+        }
+    }
+
+    public NumberFormat numberFormat() {
+        return numberFormat;
+    }
+
+    private void numberFormat(NumberFormat numberFormat) {
+        if (Objects.equals(numberFormat(), numberFormat)) {
+            return;
+        }
+        this.numberFormat = numberFormat;
+        markChanged();
+    }
+
+    public void updateProperties(Scoreboard scoreboard, int score, Component displayName, NumberFormat numberFormat) {
+        score(score);
+        displayName(displayName, scoreboard);
+        numberFormat(numberFormat);
+    }
+
+    public long lastUpdate() {
+        return lastUpdate;
+    }
+
+    public boolean isRemoved() {
+        return lastUpdate == LAST_UPDATE_REMOVE;
+    }
+
+    public void markChanged() {
+        if (lastUpdate == LAST_UPDATE_REMOVE) {
+            return;
+        }
+        lastUpdate = System.currentTimeMillis();
+    }
+
+    public void markDeleted() {
+        lastUpdate = -1;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java
index acce86f4d..6e0867ddc 100644
--- a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java
@@ -25,43 +25,72 @@
 
 package org.geysermc.geyser.scoreboard;
 
+import static org.geysermc.geyser.scoreboard.UpdateType.REMOVE;
+
 import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import lombok.Getter;
+import net.kyori.adventure.text.Component;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
 import org.cloudburstmc.protocol.bedrock.data.command.CommandEnumConstraint;
-import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket;
-import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
 import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
 import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.scoreboard.display.slot.BelownameDisplaySlot;
+import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot;
+import org.geysermc.geyser.scoreboard.display.slot.PlayerlistDisplaySlot;
+import org.geysermc.geyser.scoreboard.display.slot.SidebarDisplaySlot;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
 import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
 import org.jetbrains.annotations.Contract;
 
-import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-import static org.geysermc.geyser.scoreboard.UpdateType.*;
-
+/**
+ * Here follows some information about how scoreboards work in Java Edition, that is related to the workings of this
+ * class:
+ * <p>
+ * Objectives can be divided in two states: inactive and active.
+ * Inactive objectives is the default state for objectives that have been created using the SetObjective packet.
+ * Scores can be added, updated and removed, but as long as they're inactive they aren't shown to the player.
+ * An objective becomes active when a SetDisplayObjective packet is received, which contains the slot that
+ * the objective should be displayed at.
+ * <p>
+ * While Bedrock can handle showing one objective on multiple slots at the same time, we have to help Bedrock a bit
+ * for example by limiting the amount of sidebar scores to the amount of lines that can be shown
+ * (otherwise Bedrock may lag) and only showing online players in the playerlist (otherwise it's too cluttered.)
+ * This fact is the biggest contributor for the class being structured like it is.
+ */
 public final class Scoreboard {
     private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "true"));
     private static final boolean ADD_TEAM_SUGGESTIONS = Boolean.parseBoolean(System.getProperty("Geyser.AddTeamSuggestions", "true"));
 
     private final GeyserSession session;
     private final GeyserLogger logger;
-    @Getter
     private final AtomicLong nextId = new AtomicLong(0);
 
     private final Map<String, Objective> objectives = new ConcurrentHashMap<>();
     @Getter
-    private final Map<ScoreboardPosition, Objective> objectiveSlots = new EnumMap<>(ScoreboardPosition.class);
+    private final Map<ScoreboardPosition, DisplaySlot> objectiveSlots = Collections.synchronizedMap(new EnumMap<>(ScoreboardPosition.class));
+    private final List<DisplaySlot> removedSlots = Collections.synchronizedList(new ArrayList<>());
+
     private final Map<String, Team> teams = new ConcurrentHashMap<>(); // updated on multiple threads
     /**
      * Required to preserve vanilla behavior, which also uses a map.
@@ -71,6 +100,7 @@ public final class Scoreboard {
     @Getter
     private final Map<String, Team> playerToTeam = new Object2ObjectOpenHashMap<>();
 
+    private final AtomicBoolean updateLockActive = new AtomicBoolean(false);
     private int lastAddScoreCount = 0;
     private int lastRemoveScoreCount = 0;
 
@@ -80,24 +110,22 @@ public final class Scoreboard {
     }
 
     public void removeScoreboard() {
-        Iterator<Objective> iterator = objectives.values().iterator();
-        while (iterator.hasNext()) {
-            Objective objective = iterator.next();
-            iterator.remove();
+        var copy = new HashMap<>(objectiveSlots);
+        objectiveSlots.clear();
 
-            deleteObjective(objective, false);
+        for (DisplaySlot slot : copy.values()) {
+            slot.remove();
         }
     }
 
     public @Nullable Objective registerNewObjective(String objectiveId) {
         Objective objective = objectives.get(objectiveId);
         if (objective != null) {
-            // we have no other choice, or we have to make a new map?
-            // if the objective hasn't been deleted, we have to force it
-            if (objective.getUpdateType() != REMOVE) {
-                return null;
+            // matches vanilla behaviour
+            if (SHOW_SCOREBOARD_LOGS) {
+                logger.warning("An objective with the same name '" + objectiveId + "' already exists! Ignoring new objective!");
             }
-            deleteObjective(objective, true);
+            return null;
         }
 
         objective = new Objective(this, objectiveId);
@@ -105,273 +133,162 @@ public final class Scoreboard {
         return objective;
     }
 
-    public void displayObjective(String objectiveId, ScoreboardPosition displaySlot) {
+    public void displayObjective(String objectiveId, ScoreboardPosition slot) {
+        if (objectiveId.isEmpty()) {
+            // matches vanilla behaviour
+            var display = objectiveSlots.get(slot);
+            if (display != null) {
+                removedSlots.add(display);
+                objectiveSlots.remove(slot, display);
+                var objective = display.objective();
+                objective.removeDisplaySlot(display);
+            }
+            return;
+        }
+
         Objective objective = objectives.get(objectiveId);
         if (objective == null) {
             return;
         }
 
-        if (!objective.isActive()) {
-            objective.setActive(displaySlot);
-            // for reactivated objectives
-            objective.setUpdateType(ADD);
+        var display = objectiveSlots.get(slot);
+        if (display != null && display.objective() != objective) {
+            removedSlots.add(display);
         }
 
-        Objective storedObjective = objectiveSlots.get(displaySlot);
-        if (storedObjective != null && storedObjective != objective) {
-            storedObjective.pendingRemove();
-        }
-        objectiveSlots.put(displaySlot, objective);
-
-        if (displaySlot == ScoreboardPosition.BELOW_NAME) {
-            // Display the below name score option to all players
-            // Of note: unlike Bedrock, if there is an objective in the below name slot, everyone has a display
-            for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) {
-                if (!entity.isValid()) {
-                    // Player hasn't spawned yet - don't bother, it'll be done then
-                    continue;
-                }
-
-                entity.setBelowNameText(objective);
-            }
-        }
+        display = switch (DisplaySlot.slotCategory(slot)) {
+            case SIDEBAR -> new SidebarDisplaySlot(session, objective, slot);
+            case BELOW_NAME -> new BelownameDisplaySlot(session, objective);
+            case PLAYER_LIST -> new PlayerlistDisplaySlot(session, objective);
+            default -> throw new IllegalStateException("Unexpected value: " + slot);
+        };
+        objectiveSlots.put(slot, display);
+        objective.addDisplaySlot(display);
     }
 
-    public Team registerNewTeam(String teamName, String[] players) {
+    public void registerNewTeam(
+        String teamName,
+        String[] players,
+        Component name,
+        Component prefix,
+        Component suffix,
+        NameTagVisibility visibility,
+        TeamColor color
+    ) {
         Team team = teams.get(teamName);
         if (team != null) {
             if (SHOW_SCOREBOARD_LOGS) {
                 logger.info(GeyserLocale.getLocaleStringLog("geyser.network.translator.team.failed_overrides", teamName));
             }
-            return team;
+            return;
         }
 
-        team = new Team(this, teamName);
-        team.addEntities(players);
+        team = new Team(this, teamName, players, name, prefix, suffix, visibility, color);
         teams.put(teamName, team);
 
         // Update command parameters - is safe to send even if the command enum doesn't exist on the client (as of 1.19.51)
         if (ADD_TEAM_SUGGESTIONS) {
-            session.addCommandEnum("Geyser_Teams", team.getId());
+            session.addCommandEnum("Geyser_Teams", team.id());
         }
-        return team;
     }
 
     public void onUpdate() {
+        // if an update is already running, let it finish
+        if (updateLockActive.getAndSet(true)) {
+            return;
+        }
+
         List<ScoreInfo> addScores = new ArrayList<>(lastAddScoreCount);
         List<ScoreInfo> removeScores = new ArrayList<>(lastRemoveScoreCount);
-        List<Objective> removedObjectives = new ArrayList<>();
 
         Team playerTeam = getTeamFor(session.getPlayerEntity().getUsername());
-        Objective correctSidebar = null;
+        DisplaySlot correctSidebarSlot = null;
 
-        for (Objective objective : objectives.values()) {
-            // objective has been deleted
-            if (objective.getUpdateType() == REMOVE) {
-                removedObjectives.add(objective);
+        for (DisplaySlot slot : objectiveSlots.values()) {
+            // slot has been removed
+            if (slot.updateType() == REMOVE) {
                 continue;
             }
 
-            // there's nothing we can do with inactive objectives
-            // after checking if the objective has been deleted,
-            // except waiting for the objective to become activated (:
-            if (!objective.isActive()) {
-                continue;
-            }
-
-            if (playerTeam != null && playerTeam.getColor() == objective.getTeamColor()) {
-                correctSidebar = objective;
+            if (playerTeam != null && playerTeam.color() == slot.teamColor()) {
+                correctSidebarSlot = slot;
             }
         }
 
-        if (correctSidebar == null) {
-            correctSidebar = objectiveSlots.get(ScoreboardPosition.SIDEBAR);
+        if (correctSidebarSlot == null) {
+            correctSidebarSlot = objectiveSlots.get(ScoreboardPosition.SIDEBAR);
         }
 
-        for (Objective objective : removedObjectives) {
+        var actualRemovedSlots = new ArrayList<>(removedSlots);
+        for (var slot : actualRemovedSlots) {
             // Deletion must be handled before the active objectives are handled - otherwise if a scoreboard display is changed before the current
             // scoreboard is removed, the client can crash
-            deleteObjective(objective, true);
+            slot.remove();
         }
+        removedSlots.removeAll(actualRemovedSlots);
 
-        handleObjective(objectiveSlots.get(ScoreboardPosition.PLAYER_LIST), addScores, removeScores);
-        handleObjective(correctSidebar, addScores, removeScores);
-        handleObjective(objectiveSlots.get(ScoreboardPosition.BELOW_NAME), addScores, removeScores);
-
-        Iterator<Team> teamIterator = teams.values().iterator();
-        while (teamIterator.hasNext()) {
-            Team current = teamIterator.next();
-
-            switch (current.getCachedUpdateType()) {
-                case ADD, UPDATE -> current.markUpdated();
-                case REMOVE -> teamIterator.remove();
-            }
-        }
+        handleDisplaySlot(objectiveSlots.get(ScoreboardPosition.PLAYER_LIST), addScores, removeScores);
+        handleDisplaySlot(correctSidebarSlot, addScores, removeScores);
+        handleDisplaySlot(objectiveSlots.get(ScoreboardPosition.BELOW_NAME), addScores, removeScores);
 
         if (!removeScores.isEmpty()) {
-            SetScorePacket setScorePacket = new SetScorePacket();
-            setScorePacket.setAction(SetScorePacket.Action.REMOVE);
-            setScorePacket.setInfos(removeScores);
-            session.sendUpstreamPacket(setScorePacket);
+            SetScorePacket packet = new SetScorePacket();
+            packet.setAction(SetScorePacket.Action.REMOVE);
+            packet.setInfos(removeScores);
+            session.sendUpstreamPacket(packet);
         }
 
         if (!addScores.isEmpty()) {
-            SetScorePacket setScorePacket = new SetScorePacket();
-            setScorePacket.setAction(SetScorePacket.Action.SET);
-            setScorePacket.setInfos(addScores);
-            session.sendUpstreamPacket(setScorePacket);
+            SetScorePacket packet = new SetScorePacket();
+            packet.setAction(SetScorePacket.Action.SET);
+            packet.setInfos(addScores);
+            session.sendUpstreamPacket(packet);
         }
 
         lastAddScoreCount = addScores.size();
         lastRemoveScoreCount = removeScores.size();
+        updateLockActive.set(false);
     }
 
-    private void handleObjective(Objective objective, List<ScoreInfo> addScores, List<ScoreInfo> removeScores) {
-        if (objective == null || objective.getUpdateType() == REMOVE) {
-            return;
+    private void handleDisplaySlot(DisplaySlot slot, List<ScoreInfo> addScores, List<ScoreInfo> removeScores) {
+        if (slot != null) {
+            slot.render(addScores, removeScores);
         }
-
-        // hearts can't hold teams, so we treat them differently
-        if (objective.getType() == 1) {
-            for (Score score : objective.getScores().values()) {
-                boolean update = score.shouldUpdate();
-
-                if (update) {
-                    score.update(objective);
-                }
-
-                if (score.getUpdateType() != REMOVE && update) {
-                    addScores.add(score.getCachedInfo());
-                }
-                if (score.getUpdateType() != ADD && update) {
-                    removeScores.add(score.getCachedInfo());
-                }
-            }
-            return;
-        }
-
-        boolean objectiveAdd = objective.getUpdateType() == ADD;
-        boolean objectiveUpdate = objective.getUpdateType() == UPDATE;
-
-        for (Score score : objective.getScores().values()) {
-            if (score.getUpdateType() == REMOVE) {
-                ScoreInfo cachedInfo = score.getCachedInfo();
-                // cachedInfo can be null here when ScoreboardUpdater is being used and a score is added and
-                // removed before a single update cycle is performed
-                if (cachedInfo != null) {
-                    removeScores.add(cachedInfo);
-                }
-                // score is pending to be removed, so we can remove it from the objective
-                objective.removeScore0(score.getName());
-                break;
-            }
-
-            Team team = score.getTeam();
-
-            boolean add = objectiveAdd || objectiveUpdate;
-
-            if (team != null) {
-                if (team.getUpdateType() == REMOVE || !team.hasEntity(score.getName())) {
-                    score.setTeam(null);
-                    add = true;
-                }
-            }
-
-            if (score.shouldUpdate()) {
-                score.update(objective);
-                add = true;
-            }
-
-            if (add) {
-                addScores.add(score.getCachedInfo());
-            }
-
-            // we need this as long as MCPE-143063 hasn't been fixed.
-            // the checks after 'add' are there to prevent removing scores that
-            // are going to be removed anyway / don't need to be removed
-            if (add && score.getUpdateType() != ADD && !(objectiveUpdate || objectiveAdd)) {
-                removeScores.add(score.getCachedInfo());
-            }
-
-            score.setUpdateType(NOTHING);
-        }
-
-        if (objectiveUpdate) {
-            RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket();
-            removeObjectivePacket.setObjectiveId(objective.getObjectiveName());
-            session.sendUpstreamPacket(removeObjectivePacket);
-        }
-
-        if (objectiveAdd || objectiveUpdate) {
-            SetDisplayObjectivePacket displayObjectivePacket = new SetDisplayObjectivePacket();
-            displayObjectivePacket.setObjectiveId(objective.getObjectiveName());
-            displayObjectivePacket.setDisplayName(objective.getDisplayName());
-            displayObjectivePacket.setCriteria("dummy");
-            displayObjectivePacket.setDisplaySlot(objective.getDisplaySlotName());
-            displayObjectivePacket.setSortOrder(1); // 0 = ascending, 1 = descending
-            session.sendUpstreamPacket(displayObjectivePacket);
-        }
-
-        objective.setUpdateType(NOTHING);
-    }
-
-    /**
-     * @param remove if we should remove the objective from the objectives map.
-     */
-    public void deleteObjective(Objective objective, boolean remove) {
-        if (remove) {
-            objectives.remove(objective.getObjectiveName());
-        }
-        objectiveSlots.remove(objective.getDisplaySlot(), objective);
-
-        objective.removed();
-
-        RemoveObjectivePacket removeObjectivePacket = new RemoveObjectivePacket();
-        removeObjectivePacket.setObjectiveId(objective.getObjectiveName());
-        session.sendUpstreamPacket(removeObjectivePacket);
     }
 
     public Objective getObjective(String objectiveName) {
         return objectives.get(objectiveName);
     }
 
-    public Collection<Objective> getObjectives() {
-        return objectives.values();
-    }
-
-    public void unregisterObjective(String objectiveName) {
-        Objective objective = getObjective(objectiveName);
-        if (objective != null) {
-            objective.pendingRemove();
+    public void removeObjective(Objective objective) {
+        objectives.remove(objective.getObjectiveName());
+        for (DisplaySlot slot : objective.getActiveSlots()) {
+            objectiveSlots.remove(slot.position(), slot);
+            removedSlots.add(slot);
         }
     }
 
-    public Objective getSlot(ScoreboardPosition slot) {
-        return objectiveSlots.get(slot);
+    public void resetPlayerScores(String playerNameOrEntityUuid) {
+        for (Objective objective : objectives.values()) {
+            objective.removeScore(playerNameOrEntityUuid);
+        }
     }
 
     public Team getTeam(String teamName) {
         return teams.get(teamName);
     }
 
-    public Team getTeamFor(String entity) {
-        return playerToTeam.get(entity);
+    public Team getTeamFor(String playerNameOrEntityUuid) {
+        return playerToTeam.get(playerNameOrEntityUuid);
     }
 
     public void removeTeam(String teamName) {
         Team remove = teams.remove(teamName);
-        if (remove != null) {
-            remove.setUpdateType(REMOVE);
-            // We need to use the direct entities list here, so #refreshSessionPlayerDisplays also updates accordingly
-            // With the player's lack of a team in visibility checks
-            updateEntityNames(remove, remove.getEntities(), true);
-            for (String name : remove.getEntities()) {
-                // 1.19.3 Mojmap Scoreboard#removePlayerTeam(PlayerTeam)
-                playerToTeam.remove(name);
-            }
-
-            session.removeCommandEnum("Geyser_Teams", remove.getId());
+        if (remove == null) {
+            return;
         }
+        remove.remove();
+        session.removeCommandEnum("Geyser_Teams", remove.id());
     }
 
     @Contract("-> new")
@@ -381,48 +298,46 @@ public final class Scoreboard {
                         (o1, o2) -> o1, LinkedHashMap::new));
     }
 
-    /**
-     * Updates the display names of all entities in a given team.
-     * @param teamChange the players have either joined or left the team. Used for optimizations when just the display name updated.
-     */
-    public void updateEntityNames(Team team, boolean teamChange) {
-        Set<String> names = new HashSet<>(team.getEntities());
-        updateEntityNames(team, names, teamChange);
+    public void playerRegistered(PlayerEntity player) {
+        for (DisplaySlot slot : objectiveSlots.values()) {
+            slot.playerRegistered(player);
+        }
     }
 
-    /**
-     * Updates the display name of a set of entities within a given team. The team may also be null if the set is being removed
-     * from a team.
-     */
-    public void updateEntityNames(@Nullable Team team, Set<String> names, boolean teamChange) {
-        if (names.remove(session.getPlayerEntity().getUsername()) && teamChange) {
-            // If the player's team changed, then other entities' teams may modify their visibility based on team status
-            refreshSessionPlayerDisplays();
+    public void playerRemoved(PlayerEntity player) {
+        for (DisplaySlot slot : objectiveSlots.values()) {
+            slot.playerRemoved(player);
         }
-        if (!names.isEmpty()) {
-            for (Entity entity : session.getEntityCache().getEntities().values()) {
-                // This more complex logic is for the future to iterate over all entities, not just players
-                if (entity instanceof PlayerEntity player && names.remove(player.getUsername())) {
-                    player.updateDisplayName(team);
-                    player.updateBedrockMetadata();
-                    if (names.isEmpty()) {
-                        break;
-                    }
-                }
+    }
+
+    public void entityRegistered(Entity entity) {
+        var team = getTeamFor(entity.teamIdentifier());
+        if (team != null) {
+            team.onEntitySpawn(entity);
+        }
+    }
+
+    public void entityRemoved(Entity entity) {
+        var team = getTeamFor(entity.teamIdentifier());
+        if (team != null) {
+            team.onEntityRemove(entity);
+        }
+    }
+
+    public void setTeamFor(Team team, Set<String> entities) {
+        for (DisplaySlot slot : objectiveSlots.values()) {
+            // only sidebar slots use teams
+            if (slot instanceof SidebarDisplaySlot sidebar) {
+                sidebar.setTeamFor(team, entities);
             }
         }
     }
 
-    /**
-     * If the team's player was refreshed, then we need to go through every entity and check...
-     */
-    private void refreshSessionPlayerDisplays() {
-        for (Entity entity : session.getEntityCache().getEntities().values()) {
-            if (entity instanceof PlayerEntity player) {
-                Team playerTeam = session.getWorldCache().getScoreboard().getTeamFor(player.getUsername());
-                player.updateDisplayName(playerTeam);
-                player.updateBedrockMetadata();
-            }
-        }
+    public long nextId() {
+        return nextId.getAndIncrement();
+    }
+
+    public GeyserSession session() {
+        return session;
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java
index 395eb9576..18a4bce39 100644
--- a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java
@@ -173,7 +173,6 @@ public final class ScoreboardUpdater extends Thread {
     @Getter
     public static final class ScoreboardSession {
         private final GeyserSession session;
-        @SuppressWarnings("WriteOnlyObject")
         private final AtomicInteger pendingPacketsPerSecond = new AtomicInteger(0);
         private int packetsPerSecond;
         private long lastUpdate;
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java
index cdf2e247e..d7c06ac4f 100644
--- a/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Team.java
@@ -25,48 +25,66 @@
 
 package org.geysermc.geyser.scoreboard;
 
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
 import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
-import lombok.AccessLevel;
-import lombok.Getter;
-import lombok.Setter;
-import lombok.experimental.Accessors;
-import org.checkerframework.checker.nullness.qual.NonNull;
-import org.checkerframework.checker.nullness.qual.Nullable;
-
 import java.util.HashSet;
 import java.util.Set;
+import net.kyori.adventure.text.Component;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.entity.type.Entity;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
 
-@Getter
-@Accessors(chain = true)
 public final class Team {
+    public static final long LAST_UPDATE_DEFAULT = -1;
+    private static final long LAST_UPDATE_REMOVE = -2;
+
     private final Scoreboard scoreboard;
     private final String id;
 
-    @Getter(AccessLevel.PACKAGE)
     private final Set<String> entities;
+    private final Set<Entity> managedEntities;
     @NonNull private NameTagVisibility nameTagVisibility = NameTagVisibility.ALWAYS;
-    @Setter private TeamColor color;
+    private TeamColor color;
 
-    private final TeamData currentData;
-    private TeamData cachedData;
+    private String name;
+    private String prefix;
+    private String suffix;
+    private long lastUpdate;
 
-    private boolean updating;
-
-    public Team(Scoreboard scoreboard, String id) {
+    public Team(
+        Scoreboard scoreboard,
+        String id,
+        String[] players,
+        Component name,
+        Component prefix,
+        Component suffix,
+        NameTagVisibility visibility,
+        TeamColor color
+    ) {
         this.scoreboard = scoreboard;
         this.id = id;
-        currentData = new TeamData();
-        entities = new ObjectOpenHashSet<>();
+        this.entities = new ObjectOpenHashSet<>();
+        this.managedEntities = new ObjectOpenHashSet<>();
+        this.lastUpdate = LAST_UPDATE_DEFAULT;
+
+        // doesn't call entity update
+        updateProperties(name, prefix, suffix, visibility, color);
+        // calls entity update
+        addEntities(players);
+        lastUpdate = LAST_UPDATE_DEFAULT;
     }
 
-    public Set<String> addEntities(String... names) {
+    public void addEntities(String... names) {
         Set<String> added = new HashSet<>();
         for (String name : names) {
-            if (entities.add(name)) {
-                added.add(name);
+            // go to next score if score is already present
+            if (!entities.add(name)) {
+                continue;
             }
+            added.add(name);
             scoreboard.getPlayerToTeam().compute(name, (player, oldTeam) -> {
                 if (oldTeam != null) {
                     // Remove old team from this map, and from the set of players of the old team.
@@ -78,26 +96,15 @@ public final class Team {
         }
 
         if (added.isEmpty()) {
-            return added;
+            return;
         }
-        // we don't have to change the updateType,
-        // because the scores itself need updating, not the team
-        for (Objective objective : scoreboard.getObjectives()) {
-            for (String addedEntity : added) {
-                Score score = objective.getScores().get(addedEntity);
-                if (score != null) {
-                    score.setTeam(this);
-                }
-            }
-        }
-
-        return added;
+        // we don't have to change our updateType,
+        // because the scores themselves need updating, not the team
+        scoreboard.setTeamFor(this, added);
+        addAddedEntities(added);
     }
 
-    /**
-     * @return all removed entities from this team
-     */
-    public Set<String> removeEntities(String... names) {
+    public void removeEntities(String... names) {
         Set<String> removed = new HashSet<>();
         for (String name : names) {
             if (entities.remove(name)) {
@@ -105,87 +112,22 @@ public final class Team {
             }
             scoreboard.getPlayerToTeam().remove(name, this);
         }
-        return removed;
+        removeRemovedEntities(removed);
     }
 
     public boolean hasEntity(String name) {
         return entities.contains(name);
     }
 
-    public Team setName(String name) {
-        currentData.name = name;
-        return this;
-    }
-
-    public Team setPrefix(String prefix) {
-        // replace "null" to an empty string,
-        // we do this here to improve the performance of Score#getDisplayName
-        if (prefix.length() == 4 && "null".equals(prefix)) {
-            currentData.prefix = "";
-            return this;
+    public String displayName(String score) {
+        String chatColor = ChatColor.chatColorFor(color);
+        // most sidebar plugins will use the reset color, because they don't want color
+        // skip the unneeded double reset color in that case
+        if (ChatColor.RESET.equals(chatColor)) {
+            chatColor = "";
         }
-        currentData.prefix = prefix;
-        return this;
-    }
-
-    public Team setSuffix(String suffix) {
-        // replace "null" to an empty string,
-        // we do this here to improve the performance of Score#getDisplayName
-        if (suffix.length() == 4 && "null".equals(suffix)) {
-            currentData.suffix = "";
-            return this;
-        }
-        currentData.suffix = suffix;
-        return this;
-    }
-
-    public String getDisplayName(String score) {
-        return cachedData != null ?
-                cachedData.getDisplayName(score) :
-                currentData.getDisplayName(score);
-    }
-
-    public void markUpdated() {
-        updating = false;
-    }
-
-    public boolean shouldUpdate() {
-        return updating || cachedData == null || currentData.changed;
-    }
-
-    public void prepareUpdate() {
-        if (updating) {
-            return;
-        }
-        updating = true;
-
-        if (cachedData == null) {
-            cachedData = new TeamData();
-            cachedData.updateType = currentData.updateType != UpdateType.REMOVE ? UpdateType.ADD : UpdateType.REMOVE;
-        } else {
-            cachedData.updateType = currentData.updateType;
-        }
-
-        currentData.changed = false;
-        cachedData.name = currentData.name;
-        cachedData.prefix = currentData.prefix;
-        cachedData.suffix = currentData.suffix;
-    }
-
-    public UpdateType getUpdateType() {
-        return currentData.updateType;
-    }
-
-    public UpdateType getCachedUpdateType() {
-        return cachedData != null ? cachedData.updateType : currentData.updateType;
-    }
-
-    public Team setUpdateType(UpdateType updateType) {
-        if (updateType != UpdateType.NOTHING) {
-            currentData.changed = true;
-        }
-        currentData.updateType = updateType;
-        return this;
+        // also add reset because setting the color does not reset the formatting, unlike Java
+        return chatColor + prefix + ChatColor.RESET + chatColor + score + ChatColor.RESET + chatColor + suffix;
     }
 
     public boolean isVisibleFor(String entity) {
@@ -201,34 +143,178 @@ public final class Team {
         };
     }
 
-    public Team setNameTagVisibility(@Nullable NameTagVisibility nameTagVisibility) {
-        if (nameTagVisibility != null) {
-            // Null check like this (and this.nameTagVisibility defaults to ALWAYS) as of Java 1.19.4
-            this.nameTagVisibility = nameTagVisibility;
+    public void updateProperties(Component name, Component prefix, Component suffix, NameTagVisibility visibility, TeamColor color) {
+        // this shouldn't happen but hey!
+        if (lastUpdate == LAST_UPDATE_REMOVE) {
+            return;
         }
-        return this;
+
+        String oldName = this.name;
+        String oldPrefix = this.prefix;
+        String oldSuffix = this.suffix;
+        boolean oldVisible = isVisibleFor(playerName());
+        var oldColor = this.color;
+
+        this.name = MessageTranslator.convertMessageRaw(name, session().locale());
+        this.prefix = MessageTranslator.convertMessageRaw(prefix, session().locale());
+        this.suffix = MessageTranslator.convertMessageRaw(suffix, session().locale());
+        // matches vanilla behaviour, the visibility is not reset (to ALWAYS) if it is null.
+        // instead the visibility is not altered
+        if (visibility != null) {
+            this.nameTagVisibility = visibility;
+        }
+        this.color = color;
+
+        if (lastUpdate == LAST_UPDATE_DEFAULT) {
+            // addEntities is called after the initial updateProperties, so no need to do any entity updates here
+            if (this.color != TeamColor.RESET || !this.prefix.isEmpty() || !this.suffix.isEmpty()) {
+                markChanged();
+            }
+            return;
+        }
+
+        if (!this.name.equals(oldName)
+            || !this.prefix.equals(oldPrefix)
+            || !this.suffix.equals(oldSuffix)
+            || color != oldColor) {
+            markChanged();
+            updateEntities();
+            return;
+        }
+
+        if (isVisibleFor(playerName()) != oldVisible) {
+            // if just the visibility changed, we only have to update the entities.
+            // We don't have to mark it as changed
+            updateEntities();
+        }
+    }
+
+    public boolean shouldRemove() {
+        return lastUpdate == LAST_UPDATE_REMOVE;
+    }
+
+    public void markChanged() {
+        if (lastUpdate == LAST_UPDATE_REMOVE) {
+            return;
+        }
+        lastUpdate = System.currentTimeMillis();
+    }
+
+    public void remove() {
+        lastUpdate = LAST_UPDATE_REMOVE;
+
+        for (String name : entities()) {
+            // 1.19.3 Mojmap Scoreboard#removePlayerTeam(PlayerTeam)
+            scoreboard.getPlayerToTeam().remove(name);
+        }
+
+        if (entities().contains(playerName())) {
+            refreshAllEntities();
+            return;
+        }
+        for (Entity entity : managedEntities) {
+            entity.updateNametag(null);
+            entity.updateBedrockMetadata();
+        }
+    }
+
+    private void updateEntities() {
+        for (Entity entity : managedEntities) {
+            entity.updateNametag(this);
+            entity.updateBedrockMetadata();
+        }
+    }
+
+    public void onEntitySpawn(Entity entity) {
+        // I've basically ported addAddedEntities
+        if (entities.contains(entity.teamIdentifier())) {
+            managedEntities.add(entity);
+            // onEntitySpawn includes all entities but players, so it cannot contain self
+            entity.updateNametag(this);
+            entity.updateBedrockMetadata();
+        }
+    }
+
+    public void onEntityRemove(Entity entity) {
+        // we don't have to update anything, since the player is removed.
+        managedEntities.remove(entity);
+    }
+
+    private void addAddedEntities(Set<String> names) {
+        // can't contain self if none are added
+        if (names.isEmpty()) {
+            return;
+        }
+        boolean containsSelf = names.contains(playerName());
+
+        for (Entity entity : session().getEntityCache().getEntities().values()) {
+            if (names.contains(entity.teamIdentifier())) {
+                managedEntities.add(entity);
+                if (!containsSelf) {
+                    entity.updateNametag(this);
+                    entity.updateBedrockMetadata();
+                }
+            }
+        }
+
+        if (containsSelf) {
+            refreshAllEntities();
+        }
+    }
+
+    private void removeRemovedEntities(Set<String> names) {
+        boolean containsSelf = names.contains(playerName());
+
+        var iterator = managedEntities.iterator();
+        while (iterator.hasNext()) {
+            var entity = iterator.next();
+            if (names.contains(entity.teamIdentifier())) {
+                iterator.remove();
+                if (!containsSelf) {
+                    entity.updateNametag(null);
+                    entity.updateBedrockMetadata();
+                }
+            }
+        }
+
+        if (containsSelf) {
+            refreshAllEntities();
+        }
+    }
+
+    private void refreshAllEntities() {
+        for (Entity entity : session().getEntityCache().getEntities().values()) {
+            entity.updateNametag(scoreboard.getTeamFor(entity.teamIdentifier()));
+            entity.updateBedrockMetadata();
+        }
+    }
+
+    private GeyserSession session() {
+        return scoreboard.session();
+    }
+
+    private String playerName() {
+        return session().getPlayerEntity().getUsername();
+    }
+
+    public String id() {
+        return id;
+    }
+
+    public TeamColor color() {
+        return color;
+    }
+
+    public long lastUpdate() {
+        return lastUpdate;
+    }
+
+    public Set<String> entities() {
+        return entities;
     }
 
     @Override
     public int hashCode() {
         return id.hashCode();
     }
-
-    @Getter
-    public static final class TeamData {
-        private UpdateType updateType;
-        private boolean changed;
-
-        private String name;
-        private String prefix;
-        private String suffix;
-
-        private TeamData() {
-            updateType = UpdateType.ADD;
-        }
-
-        public String getDisplayName(String score) {
-            return prefix + score + suffix;
-        }
-    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java
new file mode 100644
index 000000000..8e101d66a
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/BelownameDisplayScore.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.score;
+
+import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot;
+
+public class BelownameDisplayScore extends DisplayScore {
+    private final PlayerEntity player;
+
+    public BelownameDisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference, PlayerEntity player) {
+        super(slot, scoreId, reference);
+        this.player = player;
+    }
+
+    @Override
+    public void update(Objective objective) {}
+
+    public PlayerEntity player() {
+        return player;
+    }
+
+    @Override
+    public void markUpdated() {
+        super.markUpdated();
+    }
+
+    public ScoreReference reference() {
+        return reference;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java
new file mode 100644
index 000000000..c6d70bb96
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/DisplayScore.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.score;
+
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot;
+
+public abstract class DisplayScore {
+    protected final DisplaySlot slot;
+    protected final long id;
+    protected final ScoreReference reference;
+
+    protected long lastTeamUpdate;
+    protected long lastUpdate;
+
+    public DisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference) {
+        this.slot = slot;
+        this.id = scoreId;
+        this.reference = reference;
+    }
+
+    public boolean shouldUpdate() {
+        return reference.lastUpdate() != lastUpdate;
+    }
+
+    public abstract void update(Objective objective);
+
+    public String name() {
+        return reference.name();
+    }
+
+    public int score() {
+        return reference.score();
+    }
+
+    public boolean referenceRemoved() {
+        return reference.isRemoved();
+    }
+
+    protected void markUpdated() {
+        // with the last update (also for team) we rather have an old lastUpdate
+        // (and have to update again the next cycle) than potentially losing information
+        // by fetching the lastUpdate after update was performed
+        this.lastUpdate = reference.lastUpdate();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java
new file mode 100644
index 000000000..c4d8d91be
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/PlayerlistDisplayScore.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.score;
+
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot;
+
+public final class PlayerlistDisplayScore extends DisplayScore {
+    private final long playerId;
+    private ScoreInfo cachedInfo;
+
+    public PlayerlistDisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference, long playerId) {
+        super(slot, scoreId, reference);
+        this.playerId = playerId;
+    }
+
+    @Override
+    public boolean shouldUpdate() {
+        // for player references the player's name is shown,
+        // so we only have to update when the score has changed
+        return cachedInfo == null || cachedInfo.getScore() != reference.score();
+    }
+
+    @Override
+    public void update(Objective objective) {
+        cachedInfo = new ScoreInfo(id, slot.objectiveId(), reference.score(), ScoreInfo.ScorerType.PLAYER, playerId);
+    }
+
+    public ScoreInfo cachedInfo() {
+        return cachedInfo;
+    }
+
+    public boolean exists() {
+        return cachedInfo != null;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java
new file mode 100644
index 000000000..42c0dbbf7
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/score/SidebarDisplayScore.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.score;
+
+import java.util.Objects;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.Team;
+import org.geysermc.geyser.scoreboard.display.slot.DisplaySlot;
+import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
+
+public final class SidebarDisplayScore extends DisplayScore {
+    private ScoreInfo cachedInfo;
+    private Team team;
+    private String order;
+    private boolean onlyScoreValueChanged;
+
+    public SidebarDisplayScore(DisplaySlot slot, long scoreId, ScoreReference reference) {
+        super(slot, scoreId, reference);
+        team(slot.objective().getScoreboard().getTeamFor(reference.name()));
+    }
+
+    @Override
+    public boolean shouldUpdate() {
+        return super.shouldUpdate() || shouldTeamUpdate();
+    }
+
+    private boolean shouldTeamUpdate() {
+        return team != null && team.lastUpdate() != lastTeamUpdate;
+    }
+
+    @Override
+    public void update(Objective objective) {
+        markUpdated();
+
+        String finalName = reference.name();
+        String displayName = reference.displayName();
+
+        if (displayName != null) {
+            finalName = displayName;
+        } else if (team != null) {
+            this.lastTeamUpdate = team.lastUpdate();
+            finalName = team.displayName(reference.name());
+        }
+
+        NumberFormat numberFormat = reference.numberFormat();
+        if (numberFormat == null) {
+            numberFormat = objective.getNumberFormat();
+        }
+        if (numberFormat instanceof FixedFormat fixedFormat) {
+            finalName += " " + ChatColor.RESET + MessageTranslator.convertMessage(fixedFormat.getValue(), objective.getScoreboard().session().locale());
+        }
+
+        if (order != null) {
+            finalName = order + ChatColor.RESET + finalName;
+        }
+
+        if (cachedInfo != null) {
+            onlyScoreValueChanged = finalName.equals(cachedInfo.getName());
+        }
+        cachedInfo = new ScoreInfo(id, slot.objectiveId(), reference.score(), finalName);
+    }
+
+    public String order() {
+        return order;
+    }
+
+    public DisplayScore order(String order) {
+        if (Objects.equals(this.order, order)) {
+            return this;
+        }
+        this.order = order;
+        // this guarantees an update
+        requestUpdate();
+        return this;
+    }
+
+    public Team team() {
+        return team;
+    }
+
+    public void team(Team team) {
+        if (this.team != null && team != null) {
+            if (!this.team.equals(team)) {
+                this.team = team;
+                requestUpdate();
+            }
+            return;
+        }
+        // simplified from (this.team != null && team == null) || (this.team == null && team != null)
+        if (this.team != null || team != null) {
+            this.team = team;
+            requestUpdate();
+        }
+    }
+
+    private void requestUpdate() {
+        this.lastUpdate = 0;
+    }
+
+    public ScoreInfo cachedInfo() {
+        return cachedInfo;
+    }
+
+    public boolean exists() {
+        return cachedInfo != null;
+    }
+
+    public boolean onlyScoreValueChanged() {
+        return onlyScoreValueChanged;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java
new file mode 100644
index 000000000..42a1e8c3f
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/BelownameDisplaySlot.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.slot;
+
+import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
+import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
+import java.util.List;
+import org.cloudburstmc.nbt.NbtMapBuilder;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.UpdateType;
+import org.geysermc.geyser.scoreboard.display.score.BelownameDisplayScore;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.mcprotocollib.protocol.codec.NbtComponentSerializer;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.BlankFormat;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.FixedFormat;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.NumberFormat;
+import org.geysermc.mcprotocollib.protocol.data.game.chat.numbers.StyledFormat;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+
+public class BelownameDisplaySlot extends DisplaySlot {
+    private final Long2ObjectMap<BelownameDisplayScore> displayScores = new Long2ObjectOpenHashMap<>();
+
+    public BelownameDisplaySlot(GeyserSession session, Objective objective) {
+        super(session, objective, ScoreboardPosition.BELOW_NAME);
+    }
+
+    @Override
+    protected void render0(List<ScoreInfo> addScores, List<ScoreInfo> removeScores) {
+        // how belowname works is that if the player itself has belowname as a display slot,
+        // every player entity will show a score below their name.
+        // when the objective is added, updated or removed we thus have to update the belowname for every player
+        // when an individual score is updated (score or number format) we have to update the individual player
+
+        // remove is handled in #remove()
+        if (updateType == UpdateType.ADD) {
+            for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) {
+                playerRegistered(player);
+            }
+            return;
+        }
+        if (updateType == UpdateType.UPDATE) {
+            for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) {
+                setBelowNameText(player, scoreFor(player.getUsername()));
+            }
+            updateType = UpdateType.NOTHING;
+            return;
+        }
+
+        for (var score : displayScores.values()) {
+            // we don't have to worry about a score not existing, because that's handled by both
+            // this method when an objective is added and addScore/playerRegistered.
+            // we only have to update them, if they have changed
+            // (or delete them, if the score no longer exists)
+            if (!score.shouldUpdate()) {
+                continue;
+            }
+
+            if (score.referenceRemoved()) {
+                clearBelowNameText(score.player());
+                continue;
+            }
+
+            score.markUpdated();
+            setBelowNameText(score.player(), score.reference());
+        }
+    }
+
+    @Override
+    public void remove() {
+        updateType = UpdateType.REMOVE;
+        for (PlayerEntity player : session.getEntityCache().getAllPlayerEntities()) {
+            clearBelowNameText(player);
+        }
+    }
+
+    @Override
+    public void addScore(ScoreReference reference) {
+        addDisplayScore(reference);
+    }
+
+    @Override
+    public void playerRegistered(PlayerEntity player) {
+        var reference = scoreFor(player.getUsername());
+        setBelowNameText(player, reference);
+        // keep track of score when the player is active
+        if (reference != null) {
+            // we already set the text, so we only have to update once the score does
+            addDisplayScore(player, reference).markUpdated();
+        }
+    }
+
+    @Override
+    public void playerRemoved(PlayerEntity player) {
+        displayScores.remove(player.getGeyserId());
+    }
+
+    private void addDisplayScore(ScoreReference reference) {
+        var players = session.getEntityCache().getPlayersByName(reference.name());
+        for (PlayerEntity player : players) {
+            addDisplayScore(player, reference);
+        }
+    }
+
+    private BelownameDisplayScore addDisplayScore(PlayerEntity player, ScoreReference reference) {
+        var score = new BelownameDisplayScore(this, objective.getScoreboard().nextId(), reference, player);
+        displayScores.put(player.getGeyserId(), score);
+        return score;
+    }
+
+    private void setBelowNameText(PlayerEntity player, ScoreReference reference) {
+        player.setBelowNameText(calculateBelowNameText(reference));
+        player.updateBedrockMetadata();
+    }
+
+    private void clearBelowNameText(PlayerEntity player) {
+        player.setBelowNameText(null);
+        player.updateBedrockMetadata();
+    }
+
+    private String calculateBelowNameText(ScoreReference reference) {
+        String numberString;
+        NumberFormat numberFormat = null;
+        // even if the player doesn't have a score, as long as belowname is on the client Java behaviour is
+        // to show them with a score of 0
+        int score = 0;
+        if (reference != null) {
+            score = reference.score();
+            numberFormat = reference.numberFormat();
+        }
+        if (numberFormat == null) {
+            numberFormat = objective.getNumberFormat();
+        }
+
+        if (numberFormat instanceof BlankFormat) {
+            numberString = "";
+        } else if (numberFormat instanceof FixedFormat fixedFormat) {
+            numberString = MessageTranslator.convertMessage(fixedFormat.getValue(), session.locale());
+        } else if (numberFormat instanceof StyledFormat styledFormat) {
+            NbtMapBuilder styledAmount = styledFormat.getStyle().toBuilder();
+            styledAmount.putString("text", String.valueOf(score));
+
+            numberString = MessageTranslator.convertJsonMessage(
+                NbtComponentSerializer.tagComponentToJson(styledAmount.build()).toString(), session.locale());
+        } else {
+            numberString = String.valueOf(score);
+        }
+
+        return numberString + " " + ChatColor.RESET + objective.getDisplayName();
+    }
+
+    private ScoreReference scoreFor(String username) {
+        return objective.getScores().get(username);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java
new file mode 100644
index 000000000..bac79e23e
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/DisplaySlot.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.slot;
+
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
+import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.UpdateType;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
+
+public abstract class DisplaySlot {
+    protected final GeyserSession session;
+    protected final Objective objective;
+    /**
+     * Use this instead of objective name because one objective can be shared in multiple slots,
+     * but each slot has its own logic and might not contain all scores
+     */
+    protected final String objectiveId;
+    protected final ScoreboardPosition slot;
+    protected final TeamColor teamColor;
+    protected final String positionName;
+
+    protected UpdateType updateType = UpdateType.ADD;
+
+    public DisplaySlot(GeyserSession session, Objective objective, ScoreboardPosition slot) {
+        this.session = session;
+        this.objective = objective;
+        this.objectiveId = String.valueOf(objective.getScoreboard().nextId());
+        this.slot = slot;
+        this.teamColor = teamColor(slot);
+        this.positionName = positionName(slot);
+    }
+
+    public final void render(List<ScoreInfo> addScores, List<ScoreInfo> removeScores) {
+        if (updateType == UpdateType.REMOVE) {
+            return;
+        }
+        render0(addScores, removeScores);
+    }
+
+    protected abstract void render0(List<ScoreInfo> addScores, List<ScoreInfo> removeScores);
+
+    public abstract void addScore(ScoreReference reference);
+
+    public abstract void playerRegistered(PlayerEntity player);
+    public abstract void playerRemoved(PlayerEntity player);
+
+    public void remove() {
+        updateType = UpdateType.REMOVE;
+        sendRemoveObjective();
+    }
+
+    public void markNeedsUpdate() {
+        if (updateType == UpdateType.NOTHING) {
+            updateType = UpdateType.UPDATE;
+        }
+    }
+
+    protected void sendDisplayObjective() {
+        SetDisplayObjectivePacket packet = new SetDisplayObjectivePacket();
+        packet.setObjectiveId(objectiveId());
+        packet.setDisplayName(objective.getDisplayName());
+        packet.setCriteria("dummy");
+        packet.setDisplaySlot(positionName);
+        packet.setSortOrder(1); // 0 = ascending, 1 = descending
+        session.sendUpstreamPacket(packet);
+    }
+
+    protected void sendRemoveObjective() {
+        RemoveObjectivePacket packet = new RemoveObjectivePacket();
+        packet.setObjectiveId(objectiveId());
+        session.sendUpstreamPacket(packet);
+    }
+
+    public Objective objective() {
+        return objective;
+    }
+
+    public String objectiveId() {
+        return objectiveId;
+    }
+
+    public ScoreboardPosition position() {
+        return slot;
+    }
+
+    public @Nullable TeamColor teamColor() {
+        return teamColor;
+    }
+
+    public UpdateType updateType() {
+        return updateType;
+    }
+
+    public static ScoreboardPosition slotCategory(ScoreboardPosition slot) {
+        return switch (slot) {
+            case BELOW_NAME -> ScoreboardPosition.BELOW_NAME;
+            case PLAYER_LIST -> ScoreboardPosition.PLAYER_LIST;
+            default -> ScoreboardPosition.SIDEBAR;
+        };
+    }
+
+    private static String positionName(ScoreboardPosition slot) {
+        return switch (slot) {
+            case BELOW_NAME -> "belowname";
+            case PLAYER_LIST -> "list";
+            default -> "sidebar";
+        };
+    }
+
+    private static @Nullable TeamColor teamColor(ScoreboardPosition slot) {
+        return switch (slot) {
+            case SIDEBAR_TEAM_RED -> TeamColor.RED;
+            case SIDEBAR_TEAM_AQUA -> TeamColor.AQUA;
+            case SIDEBAR_TEAM_BLUE -> TeamColor.BLUE;
+            case SIDEBAR_TEAM_GOLD -> TeamColor.GOLD;
+            case SIDEBAR_TEAM_GRAY -> TeamColor.GRAY;
+            case SIDEBAR_TEAM_BLACK -> TeamColor.BLACK;
+            case SIDEBAR_TEAM_GREEN -> TeamColor.GREEN;
+            case SIDEBAR_TEAM_WHITE -> TeamColor.WHITE;
+            case SIDEBAR_TEAM_YELLOW -> TeamColor.YELLOW;
+            case SIDEBAR_TEAM_DARK_RED -> TeamColor.DARK_RED;
+            case SIDEBAR_TEAM_DARK_AQUA -> TeamColor.DARK_AQUA;
+            case SIDEBAR_TEAM_DARK_BLUE -> TeamColor.DARK_BLUE;
+            case SIDEBAR_TEAM_DARK_GRAY -> TeamColor.DARK_GRAY;
+            case SIDEBAR_TEAM_DARK_GREEN -> TeamColor.DARK_GREEN;
+            case SIDEBAR_TEAM_DARK_PURPLE -> TeamColor.DARK_PURPLE;
+            case SIDEBAR_TEAM_LIGHT_PURPLE -> TeamColor.LIGHT_PURPLE;
+            default -> null;
+        };
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java
new file mode 100644
index 000000000..6fd83ab8d
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/PlayerlistDisplaySlot.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.slot;
+
+import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
+import it.unimi.dsi.fastutil.longs.Long2ObjectMaps;
+import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.UpdateType;
+import org.geysermc.geyser.scoreboard.display.score.PlayerlistDisplayScore;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+
+public class PlayerlistDisplaySlot extends DisplaySlot {
+    private final Long2ObjectMap<PlayerlistDisplayScore> displayScores =
+        Long2ObjectMaps.synchronize(new Long2ObjectOpenHashMap<>());
+    private final List<PlayerlistDisplayScore> removedScores = Collections.synchronizedList(new ArrayList<>());
+
+    public PlayerlistDisplaySlot(GeyserSession session, Objective objective) {
+        super(session, objective, ScoreboardPosition.PLAYER_LIST);
+        registerExisting();
+    }
+
+    @Override
+    protected void render0(List<ScoreInfo> addScores, List<ScoreInfo> removeScores) {
+        boolean objectiveAdd = updateType == UpdateType.ADD;
+        boolean objectiveUpdate = updateType == UpdateType.UPDATE;
+        boolean objectiveNothing = updateType == UpdateType.NOTHING;
+
+        // if 'add' the scores aren't present, if 'update' the objective is re-added so the scores don't have to be
+        // manually removed, if 'remove' the scores are removed anyway
+        if (objectiveNothing) {
+            var removedScoresCopy = new ArrayList<>(removedScores);
+            for (var removedScore : removedScoresCopy) {
+                //todo idk if this if-statement is needed
+                if (removedScore.cachedInfo() != null) {
+                    removeScores.add(removedScore.cachedInfo());
+                }
+            }
+            removedScores.removeAll(removedScoresCopy);
+        } else {
+            removedScores.clear();
+        }
+
+        for (var score : displayScores.values()) {
+            if (score.referenceRemoved()) {
+                ScoreInfo cachedInfo = score.cachedInfo();
+                // cachedInfo can be null here when ScoreboardUpdater is being used and a score is added and
+                // removed before a single update cycle is performed
+                if (cachedInfo != null) {
+                    removeScores.add(cachedInfo);
+                }
+                continue;
+            }
+
+            //todo does an animated title exist on tab?
+            boolean add = objectiveAdd || objectiveUpdate;
+            boolean exists = score.exists();
+
+            if (score.shouldUpdate()) {
+                score.update(objective);
+                add = true;
+            }
+
+            if (add) {
+                addScores.add(score.cachedInfo());
+            }
+
+            // we need this as long as MCPE-143063 hasn't been fixed.
+            // the checks after 'add' are there to prevent removing scores that
+            // are going to be removed anyway / don't need to be removed
+            if (add && exists && objectiveNothing) {
+                removeScores.add(score.cachedInfo());
+            }
+        }
+
+        if (objectiveUpdate) {
+            sendRemoveObjective();
+        }
+
+        if (objectiveAdd || objectiveUpdate) {
+            sendDisplayObjective();
+        }
+
+        updateType = UpdateType.NOTHING;
+    }
+
+    @Override
+    public void addScore(ScoreReference reference) {
+        // while it breaks a lot of stuff in Java, scoreboard do work fine with multiple players having
+        // the same username
+        var players = session.getEntityCache().getPlayersByName(reference.name());
+        var selfPlayer = session.getPlayerEntity();
+        if (reference.name().equals(selfPlayer.getUsername())) {
+            players.add(selfPlayer);
+        }
+
+        for (PlayerEntity player : players) {
+            var score =
+                new PlayerlistDisplayScore(this, objective.getScoreboard().nextId(), reference, player.getGeyserId());
+            displayScores.put(player.getGeyserId(), score);
+        }
+    }
+
+    private void registerExisting() {
+        playerRegistered(session.getPlayerEntity());
+        session.getEntityCache().getAllPlayerEntities().forEach(this::playerRegistered);
+    }
+
+    @Override
+    public void playerRegistered(PlayerEntity player) {
+        var reference = objective.getScores().get(player.getUsername());
+        if (reference == null) {
+            return;
+        }
+        var score =
+            new PlayerlistDisplayScore(this, objective.getScoreboard().nextId(), reference, player.getGeyserId());
+        displayScores.put(player.getGeyserId(), score);
+    }
+
+    @Override
+    public void playerRemoved(PlayerEntity player) {
+        var score = displayScores.remove(player.getGeyserId());
+        if (score == null) {
+            return;
+        }
+        removedScores.add(score);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java
new file mode 100644
index 000000000..24cc81f78
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/display/slot/SidebarDisplaySlot.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.display.slot;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.scoreboard.Objective;
+import org.geysermc.geyser.scoreboard.ScoreReference;
+import org.geysermc.geyser.scoreboard.Team;
+import org.geysermc.geyser.scoreboard.UpdateType;
+import org.geysermc.geyser.scoreboard.display.score.SidebarDisplayScore;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+
+public final class SidebarDisplaySlot extends DisplaySlot {
+    private static final int SCORE_DISPLAY_LIMIT = 15;
+    private static final Comparator<ScoreReference> SCORE_DISPLAY_ORDER =
+        Comparator.comparing(ScoreReference::score)
+            .reversed()
+            .thenComparing(ScoreReference::name, String.CASE_INSENSITIVE_ORDER);
+
+    private List<SidebarDisplayScore> displayScores = new ArrayList<>(SCORE_DISPLAY_LIMIT);
+
+    public SidebarDisplaySlot(GeyserSession session, Objective objective, ScoreboardPosition position) {
+        super(session, objective, position);
+    }
+
+    @Override
+    protected void render0(List<ScoreInfo> addScores, List<ScoreInfo> removeScores) {
+        // while one could argue that we may not have to do this fancy Java filter when there are fewer scores than the
+        // line limit, we would lose the correct order of the scores if we don't
+        var newDisplayScores =
+            objective.getScores().values().stream()
+                .filter(score -> !score.hidden())
+                .sorted(SCORE_DISPLAY_ORDER)
+                .limit(SCORE_DISPLAY_LIMIT)
+                .map(reference -> {
+                    // pretty much an ArrayList#remove
+                    var iterator = this.displayScores.iterator();
+                    while (iterator.hasNext()) {
+                        var score = iterator.next();
+                        if (score.name().equals(reference.name())) {
+                            iterator.remove();
+                            return score;
+                        }
+                    }
+
+                    // new score, so it should be added
+                    return new SidebarDisplayScore(this, objective.getScoreboard().nextId(), reference);
+                }).collect(Collectors.toList());
+
+        // in newDisplayScores we removed the items that were already present from displayScores,
+        // meaning that the items that remain are items that are no longer displayed
+        for (var score : this.displayScores) {
+            removeScores.add(score.cachedInfo());
+        }
+
+        // preserves the new order
+        this.displayScores = newDisplayScores;
+
+        // fixes ordering issues with multiple entries with same score
+        if (!this.displayScores.isEmpty()) {
+            SidebarDisplayScore lastScore = null;
+            int count = 0;
+            for (var score : this.displayScores) {
+                if (lastScore == null) {
+                    lastScore = score;
+                    continue;
+                }
+
+                if (score.score() == lastScore.score()) {
+                    // something to keep in mind is that Bedrock doesn't support some legacy color codes and adds some
+                    // codes as well, so if the line limit is every increased keep that in mind
+                    if (count == 0) {
+                        lastScore.order(ChatColor.styleOrder(count++));
+                    }
+                    score.order(ChatColor.styleOrder(count++));
+                } else {
+                    if (count == 0) {
+                        lastScore.order(null);
+                    }
+                    count = 0;
+                }
+                lastScore = score;
+            }
+
+            if (count == 0 && lastScore != null) {
+                lastScore.order(null);
+            }
+        }
+
+        boolean objectiveAdd = updateType == UpdateType.ADD;
+        boolean objectiveUpdate = updateType == UpdateType.UPDATE;
+
+        for (var score : this.displayScores) {
+            Team team = score.team();
+            boolean add = objectiveAdd || objectiveUpdate;
+            boolean exists = score.exists();
+
+            if (team != null) {
+                // entities are mostly removed from teams without notifying the scores.
+                if (team.shouldRemove() || !team.hasEntity(score.name())) {
+                    score.team(null);
+                    add = true;
+                }
+            }
+
+            if (score.shouldUpdate()) {
+                score.update(objective);
+                add = true;
+            }
+
+            if (add) {
+                addScores.add(score.cachedInfo());
+            }
+
+            // we need this as long as MCPE-143063 hasn't been fixed.
+            // the checks after 'add' are there to prevent removing scores that
+            // are going to be removed anyway / don't need to be removed
+            if (add && exists && !(objectiveUpdate || objectiveAdd) && !score.onlyScoreValueChanged()) {
+                removeScores.add(score.cachedInfo());
+            }
+        }
+
+        if (objectiveUpdate) {
+            sendRemoveObjective();
+        }
+
+        if (objectiveAdd || objectiveUpdate) {
+            sendDisplayObjective();
+        }
+
+        updateType = UpdateType.NOTHING;
+    }
+
+    @Override
+    public void addScore(ScoreReference reference) {
+        // we handle them a bit different: we sort the scores, and we add them ourselves
+    }
+
+    @Override
+    public void playerRegistered(PlayerEntity player) {
+
+    }
+
+    @Override
+    public void playerRemoved(PlayerEntity player) {
+
+    }
+
+    public void setTeamFor(Team team, Set<String> entities) {
+        // we only have to worry about scores that are currently displayed,
+        // because the constructor of the display score fetches the team
+        for (var score : displayScores) {
+            if (entities.contains(score.name())) {
+                score.team(team);
+            }
+        }
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
index 4589afe23..9c20e9909 100644
--- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
+++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
@@ -74,6 +74,7 @@ import org.cloudburstmc.protocol.bedrock.data.SpawnBiomeType;
 import org.cloudburstmc.protocol.bedrock.data.command.CommandEnumData;
 import org.cloudburstmc.protocol.bedrock.data.command.CommandPermission;
 import org.cloudburstmc.protocol.bedrock.data.command.SoftEnumUpdateType;
+import org.cloudburstmc.protocol.bedrock.data.definitions.DimensionDefinition;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
 import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData;
 import org.cloudburstmc.protocol.bedrock.packet.AvailableEntityIdentifiersPacket;
@@ -84,6 +85,7 @@ import org.cloudburstmc.protocol.bedrock.packet.ChunkRadiusUpdatedPacket;
 import org.cloudburstmc.protocol.bedrock.packet.ClientboundCloseFormPacket;
 import org.cloudburstmc.protocol.bedrock.packet.CraftingDataPacket;
 import org.cloudburstmc.protocol.bedrock.packet.CreativeContentPacket;
+import org.cloudburstmc.protocol.bedrock.packet.DimensionDataPacket;
 import org.cloudburstmc.protocol.bedrock.packet.EmoteListPacket;
 import org.cloudburstmc.protocol.bedrock.packet.GameRulesChangedPacket;
 import org.cloudburstmc.protocol.bedrock.packet.ItemComponentPacket;
@@ -175,7 +177,6 @@ import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.util.ChunkUtils;
-import org.geysermc.geyser.util.DimensionUtils;
 import org.geysermc.geyser.util.EntityUtils;
 import org.geysermc.geyser.util.LoginEncryptionUtils;
 import org.geysermc.geyser.util.MinecraftAuthLogger;
@@ -388,6 +389,10 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
     @Setter
     private boolean sprinting;
 
+    /**
+     * The overworld dimension which Bedrock Edition uses.
+     */
+    private BedrockDimension bedrockOverworldDimension = BedrockDimension.OVERWORLD;
     /**
      * The dimension of the player.
      * As all entities are in the same world, this can be safely applied to all other entities.
@@ -401,7 +406,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
      * right before the StartGamePacket is sent.
      */
     @Setter
-    private BedrockDimension bedrockDimension = BedrockDimension.OVERWORLD;
+    private BedrockDimension bedrockDimension = this.bedrockOverworldDimension;
 
     @Setter
     private int breakingBlock;
@@ -711,6 +716,31 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
      * Send all necessary packets to load Bedrock into the server
      */
     public void connect() {
+        // Note: this.dimensionType may be null here if the player is connecting from online mode
+        int minY = BedrockDimension.OVERWORLD.minY();
+        int maxY = BedrockDimension.OVERWORLD.maxY();
+        for (JavaDimension javaDimension : this.registryCache.dimensions().values()) {
+            if (javaDimension.bedrockId() == BedrockDimension.OVERWORLD_ID) {
+                minY = Math.min(minY, javaDimension.minY());
+                maxY = Math.max(maxY, javaDimension.maxY());
+            }
+        }
+        minY = Math.max(minY, -512);
+        maxY = Math.min(maxY, 512);
+
+        if (minY < BedrockDimension.OVERWORLD.minY() || maxY > BedrockDimension.OVERWORLD.maxY()) {
+            final boolean isInOverworld = this.bedrockDimension == this.bedrockOverworldDimension;
+            this.bedrockOverworldDimension = new BedrockDimension(minY, maxY - minY, true, BedrockDimension.OVERWORLD_ID);
+            if (isInOverworld) {
+                this.bedrockDimension = this.bedrockOverworldDimension;
+            }
+            geyser.getLogger().debug("Extending overworld dimension to " + minY + " - " + maxY);
+
+            DimensionDataPacket dimensionDataPacket = new DimensionDataPacket();
+            dimensionDataPacket.getDefinitions().add(new DimensionDefinition("minecraft:overworld", maxY, minY, 5 /* Void */));
+            upstream.sendPacket(dimensionDataPacket);
+        }
+
         startGame();
         sentSpawnPacket = true;
         syncEntityProperties();
@@ -933,8 +963,6 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
         // Start ticking
         tickThread = eventLoop.scheduleAtFixedRate(this::tick, 50, 50, TimeUnit.MILLISECONDS);
 
-        this.protocol.setUseDefaultListeners(false);
-
         TcpSession downstream;
         if (geyser.getBootstrap().getSocketAddress() != null) {
             // We're going to connect through the JVM and not through TCP
@@ -960,7 +988,6 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
         this.downstream.getSession().setFlag(MinecraftConstants.FOLLOW_TRANSFERS, false);
 
         if (geyser.getConfig().getRemote().isUseProxyProtocol()) {
-            downstream.setFlag(BuiltinFlags.ENABLE_CLIENT_PROXY_PROTOCOL, true);
             downstream.setFlag(BuiltinFlags.CLIENT_PROXIED_ADDRESS, upstream.getAddress());
         }
         if (geyser.getConfig().isForwardPlayerPing()) {
@@ -970,22 +997,6 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
         // We'll handle this since we have the registry data on hand
         downstream.setFlag(MinecraftConstants.SEND_BLANK_KNOWN_PACKS_RESPONSE, false);
 
-        // This isn't a great solution, but... we want to make sure the finish configuration packet cannot be sent
-        // before the KnownPacks packet.
-        this.downstream.getSession().addListener(new ClientListener(ProtocolState.LOGIN, loginEvent.transferring()) {
-            @Override
-            public void packetReceived(Session session, Packet packet) {
-                if (protocol.getState() == ProtocolState.CONFIGURATION) {
-                    if (packet instanceof ClientboundFinishConfigurationPacket) {
-                        // Prevent
-                        GeyserSession.this.ensureInEventLoop(() -> GeyserSession.this.sendDownstreamPacket(new ServerboundFinishConfigurationPacket()));
-                        return;
-                    }
-                }
-                super.packetReceived(session, packet);
-            }
-        });
-
         downstream.addListener(new SessionAdapter() {
             @Override
             public void packetSending(PacketSendingEvent event) {
@@ -1594,7 +1605,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
         startGamePacket.setRotation(Vector2f.from(1, 1));
 
         startGamePacket.setSeed(-1L);
-        startGamePacket.setDimensionId(DimensionUtils.javaToBedrock(bedrockDimension));
+        startGamePacket.setDimensionId(bedrockDimension.bedrockId());
         startGamePacket.setGeneratorId(1);
         startGamePacket.setLevelGameType(GameType.SURVIVAL);
         startGamePacket.setDifficulty(1);
@@ -1758,8 +1769,8 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
             return;
         }
 
-        if (protocol.getState() != intendedState) {
-            geyser.getLogger().debug("Tried to send " + packet.getClass().getSimpleName() + " packet while not in " + intendedState.name() + " state");
+        if (protocol.getOutboundState() != intendedState) {
+            geyser.getLogger().debug("Tried to send " + packet.getClass().getSimpleName() + " packet while not in " + intendedState.name() + " outbound state");
             return;
         }
 
@@ -1793,7 +1804,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
     }
 
     private void sendDownstreamPacket0(Packet packet) {
-        ProtocolState state = protocol.getState();
+        ProtocolState state = protocol.getOutboundState();
         if (state == ProtocolState.GAME || state == ProtocolState.CONFIGURATION || packet.getClass() == ServerboundCustomQueryAnswerPacket.class) {
             downstream.sendPacket(packet);
         } else {
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java
index 3affa12cf..a80ed3e3a 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java
@@ -31,15 +31,18 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
 import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.objects.ObjectArrayList;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
 import lombok.Getter;
 import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.type.Tickable;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.session.GeyserSession;
 
-import java.util.*;
-import java.util.concurrent.atomic.AtomicLong;
-
 /**
  * Each session has its own EntityCache in the occasion that an entity packet is sent specifically
  * for that player (e.g. seeing vanished players from /vanish)
@@ -68,6 +71,10 @@ public class EntityCache {
         if (cacheEntity(entity)) {
             entity.spawnEntity();
 
+            // start tracking newly spawned entities.
+            // This is however not called for players, that's done in addPlayerEntity
+            session.getWorldCache().getScoreboard().entityRegistered(entity);
+
             if (entity instanceof Tickable) {
                 // Start ticking it
                 tickableEntities.add((Tickable) entity);
@@ -86,21 +93,24 @@ public class EntityCache {
     }
 
     public void removeEntity(Entity entity) {
+        if (entity == null) {
+            return;
+        }
+
         if (entity instanceof PlayerEntity player) {
             session.getPlayerWithCustomHeads().remove(player.getUuid());
         }
 
-        if (entity != null) {
-            if (entity.isValid()) {
-                entity.despawnEntity();
-            }
+        if (entity.isValid()) {
+            entity.despawnEntity();
+        }
+        entities.remove(entityIdTranslations.remove(entity.getEntityId()));
 
-            long geyserId = entityIdTranslations.remove(entity.getEntityId());
-            entities.remove(geyserId);
+        // don't track the entity anymore, now that it's removed
+        session.getWorldCache().getScoreboard().entityRemoved(entity);
 
-            if (entity instanceof Tickable) {
-                tickableEntities.remove(entity);
-            }
+        if (entity instanceof Tickable) {
+            tickableEntities.remove(entity);
         }
     }
 
@@ -126,15 +136,39 @@ public class EntityCache {
 
     public void addPlayerEntity(PlayerEntity entity) {
         // putIfAbsent matches the behavior of playerInfoMap in Java as of 1.19.3
-        playerEntities.putIfAbsent(entity.getUuid(), entity);
+        boolean exists = playerEntities.putIfAbsent(entity.getUuid(), entity) != null;
+        if (exists) {
+            return;
+        }
+
+        // notify scoreboard for new entity
+        var scoreboard = session.getWorldCache().getScoreboard();
+        scoreboard.playerRegistered(entity);
+        // spawnPlayer's entityRegistered is not called for players
+        scoreboard.entityRegistered(entity);
     }
 
     public PlayerEntity getPlayerEntity(UUID uuid) {
         return playerEntities.get(uuid);
     }
 
+    public List<PlayerEntity> getPlayersByName(String name) {
+        var list = new ArrayList<PlayerEntity>();
+        for (PlayerEntity player : playerEntities.values()) {
+            if (name.equals(player.getUsername())) {
+                list.add(player);
+            }
+        }
+        return list;
+    }
+
     public PlayerEntity removePlayerEntity(UUID uuid) {
-        return playerEntities.remove(uuid);
+        var player = playerEntities.remove(uuid);
+        if (player != null) {
+            // notify scoreboard
+            session.getWorldCache().getScoreboard().playerRemoved(player);
+        }
+        return player;
     }
 
     public Collection<PlayerEntity> getAllPlayerEntities() {
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java
index 86cb69314..5927963c0 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java
@@ -31,6 +31,7 @@ import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
 import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
 import lombok.Getter;
 import lombok.Setter;
+import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.cloudburstmc.math.vector.Vector3i;
 import org.cloudburstmc.protocol.bedrock.packet.SetTitlePacket;
@@ -49,7 +50,7 @@ public final class WorldCache {
     @Getter
     private final ScoreboardSession scoreboardSession;
     @Getter
-    private Scoreboard scoreboard;
+    private @NonNull Scoreboard scoreboard;
     @Getter
     @Setter
     private Difficulty difficulty = Difficulty.EASY;
@@ -81,10 +82,8 @@ public final class WorldCache {
     }
 
     public void removeScoreboard() {
-        if (scoreboard != null) {
-            scoreboard.removeScoreboard();
-            scoreboard = new Scoreboard(session);
-        }
+        scoreboard.removeScoreboard();
+        scoreboard = new Scoreboard(session);
     }
 
     public int increaseAndGetScoreboardPacketsPerSecond() {
diff --git a/core/src/main/java/org/geysermc/geyser/text/ChatColor.java b/core/src/main/java/org/geysermc/geyser/text/ChatColor.java
index 49178f033..22e553678 100644
--- a/core/src/main/java/org/geysermc/geyser/text/ChatColor.java
+++ b/core/src/main/java/org/geysermc/geyser/text/ChatColor.java
@@ -25,6 +25,8 @@
 
 package org.geysermc.geyser.text;
 
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
+
 public class ChatColor {
     public static final String ANSI_RESET = (char) 0x1b + "[0m";
 
@@ -84,4 +86,58 @@ public class ChatColor {
         string = string.replace(WHITE, (char) 0x1b + "[37;1m");
         return string;
     }
-}
\ No newline at end of file
+
+    public static String styleOrder(int index) {
+        // https://bugs.mojang.com/browse/MCPE-41729
+        // strikethrough and underlined do not exist on Bedrock
+        return switch (index) {
+            case 0 -> BLACK;
+            case 1 -> DARK_BLUE;
+            case 2 -> DARK_GREEN;
+            case 3 -> DARK_AQUA;
+            case 4 -> DARK_RED;
+            case 5 -> DARK_PURPLE;
+            case 6 -> GOLD;
+            case 7 -> GRAY;
+            case 8 -> DARK_GRAY;
+            case 9 -> BLUE;
+            case 10 -> GREEN;
+            case 11 -> AQUA;
+            case 12 -> RED;
+            case 13 -> LIGHT_PURPLE;
+            case 14 -> YELLOW;
+            case 15 -> WHITE;
+            case 16 -> OBFUSCATED;
+            case 17 -> BOLD;
+            default -> ITALIC;
+        };
+    }
+
+    public static String chatColorFor(TeamColor teamColor) {
+        // https://bugs.mojang.com/browse/MCPE-41729
+        // strikethrough and underlined do not exist on Bedrock
+        return switch (teamColor) {
+            case BLACK -> BLACK;
+            case DARK_BLUE -> DARK_BLUE;
+            case DARK_GREEN -> DARK_GREEN;
+            case DARK_AQUA -> DARK_AQUA;
+            case DARK_RED -> DARK_RED;
+            case DARK_PURPLE -> DARK_PURPLE;
+            case GOLD -> GOLD;
+            case GRAY -> GRAY;
+            case DARK_GRAY -> DARK_GRAY;
+            case BLUE -> BLUE;
+            case GREEN -> GREEN;
+            case AQUA -> AQUA;
+            case RED -> RED;
+            case LIGHT_PURPLE -> LIGHT_PURPLE;
+            case YELLOW -> YELLOW;
+            case WHITE -> WHITE;
+            case OBFUSCATED -> OBFUSCATED;
+            case BOLD -> BOLD;
+            case STRIKETHROUGH, UNDERLINED -> "";
+            case ITALIC -> ITALIC;
+            default -> RESET;
+        };
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginTranslator.java
index 6470a5f0a..fb9159c47 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginTranslator.java
@@ -33,6 +33,7 @@ import org.geysermc.erosion.Constants;
 import org.geysermc.floodgate.pluginmessage.PluginMessageChannels;
 import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.entity.type.player.SessionPlayerEntity;
+import org.geysermc.geyser.level.BedrockDimension;
 import org.geysermc.geyser.level.JavaDimension;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
@@ -62,7 +63,7 @@ public class JavaLoginTranslator extends PacketTranslator<ClientboundLoginPacket
         // If the player is already initialized and a join game packet is sent, they
         // are swapping servers
         if (session.isSpawned()) {
-            int fakeDim = DimensionUtils.getTemporaryDimension(DimensionUtils.javaToBedrock(session.getBedrockDimension()), newDimension.bedrockId());
+            int fakeDim = DimensionUtils.getTemporaryDimension(session.getBedrockDimension().bedrockId(), newDimension.bedrockId());
             if (fakeDim != newDimension.bedrockId()) {
                 // The player's current dimension and new dimension are the same
                 // We want a dimension switch to clear old chunks out, so switch to a dimension that isn't the one we're currently in.
@@ -121,9 +122,9 @@ public class JavaLoginTranslator extends PacketTranslator<ClientboundLoginPacket
         }
         session.sendDownstreamPacket(new ServerboundCustomPayloadPacket(register, Constants.PLUGIN_MESSAGE.getBytes(StandardCharsets.UTF_8)));
 
-        if (DimensionUtils.javaToBedrock(session.getBedrockDimension()) != newDimension.bedrockId()) {
+        if (session.getBedrockDimension().bedrockId() != newDimension.bedrockId()) {
             DimensionUtils.switchDimension(session, newDimension);
-        } else if (DimensionUtils.isCustomBedrockNetherId() && newDimension.isNetherLike()) {
+        } else if (BedrockDimension.isCustomBedrockNetherId() && newDimension.isNetherLike()) {
             // If the player is spawning into the "fake" nether, send them some fog
             session.camera().sendFog(DimensionUtils.BEDROCK_FOG_HELL);
         }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java
index 0e5d19785..af3c8595e 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java
@@ -29,7 +29,12 @@ import io.netty.buffer.ByteBuf;
 import io.netty.buffer.ByteBufAllocator;
 import io.netty.buffer.ByteBufOutputStream;
 import io.netty.buffer.Unpooled;
-import it.unimi.dsi.fastutil.ints.*;
+import it.unimi.dsi.fastutil.ints.IntArrayList;
+import it.unimi.dsi.fastutil.ints.IntImmutableList;
+import it.unimi.dsi.fastutil.ints.IntList;
+import it.unimi.dsi.fastutil.ints.IntLists;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
 import it.unimi.dsi.fastutil.objects.ObjectArrayList;
 import org.cloudburstmc.math.vector.Vector3i;
 import org.cloudburstmc.nbt.NBTOutputStream;
@@ -56,7 +61,6 @@ import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.BlockEntityUtils;
 import org.geysermc.geyser.util.ChunkUtils;
-import org.geysermc.geyser.util.DimensionUtils;
 import org.geysermc.mcprotocollib.protocol.data.game.chunk.BitStorage;
 import org.geysermc.mcprotocollib.protocol.data.game.chunk.ChunkSection;
 import org.geysermc.mcprotocollib.protocol.data.game.chunk.DataPalette;
@@ -509,7 +513,7 @@ public class JavaLevelChunkWithLightTranslator extends PacketTranslator<Clientbo
         levelChunkPacket.setChunkX(packet.getX());
         levelChunkPacket.setChunkZ(packet.getZ());
         levelChunkPacket.setData(Unpooled.wrappedBuffer(payload));
-        levelChunkPacket.setDimension(DimensionUtils.javaToBedrock(session.getBedrockDimension()));
+        levelChunkPacket.setDimension(session.getBedrockDimension().bedrockId());
         session.sendUpstreamPacket(levelChunkPacket);
 
         for (Map.Entry<Vector3i, ItemFrameEntity> entry : session.getItemFrameCache().entrySet()) {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java
index e8d307c90..cf688bbfd 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaResetScorePacket.java
@@ -32,40 +32,22 @@ import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.cache.WorldCache;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
 import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundResetScorePacket;
 
 @Translator(packet = ClientboundResetScorePacket.class)
 public class JavaResetScorePacket extends PacketTranslator<ClientboundResetScorePacket> {
-
     @Override
     public void translate(GeyserSession session, ClientboundResetScorePacket packet) {
         WorldCache worldCache = session.getWorldCache();
         Scoreboard scoreboard = worldCache.getScoreboard();
         int pps = worldCache.increaseAndGetScoreboardPacketsPerSecond();
 
-        Objective belowName = scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME);
-
         if (packet.getObjective() == null) {
             // No objective name means all scores are reset for that player (/scoreboard players reset PLAYERNAME)
-            for (Objective otherObjective : scoreboard.getObjectives()) {
-                otherObjective.removeScore(packet.getOwner());
-            }
-
-            // as described below
-            if (belowName != null) {
-                JavaSetScoreTranslator.setBelowName(session, belowName, packet.getOwner());
-            }
+            scoreboard.resetPlayerScores(packet.getOwner());
         } else {
             Objective objective = scoreboard.getObjective(packet.getObjective());
             objective.removeScore(packet.getOwner());
-
-            // If this is the objective that is in use to show the below name text, we need to update the player
-            // attached to this score.
-            if (objective == belowName) {
-                // Update the score on this player to now reflect 0
-                JavaSetScoreTranslator.setBelowName(session, objective, packet.getOwner());
-            }
         }
 
         // ScoreboardUpdater will handle it for us if the packets per second
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java
index 85d93c0b5..0a7c6131f 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetObjectiveTranslator.java
@@ -25,72 +25,45 @@
 
 package org.geysermc.geyser.translator.protocol.java.scoreboard;
 
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
-import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
-import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.scoreboard.Objective;
 import org.geysermc.geyser.scoreboard.Scoreboard;
 import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
-import org.geysermc.geyser.scoreboard.UpdateType;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.cache.WorldCache;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
 
 @Translator(packet = ClientboundSetObjectivePacket.class)
 public class JavaSetObjectiveTranslator extends PacketTranslator<ClientboundSetObjectivePacket> {
-    private final GeyserLogger logger = GeyserImpl.getInstance().getLogger();
-
     @Override
     public void translate(GeyserSession session, ClientboundSetObjectivePacket packet) {
         WorldCache worldCache = session.getWorldCache();
         Scoreboard scoreboard = worldCache.getScoreboard();
         int pps = worldCache.increaseAndGetScoreboardPacketsPerSecond();
 
-        Objective objective = scoreboard.getObjective(packet.getName());
-        if (objective != null && objective.getUpdateType() != UpdateType.REMOVE && packet.getAction() == ObjectiveAction.ADD) {
-            // matches vanilla behaviour
-            logger.warning("An objective with the same name '" + packet.getName() + "' already exists! Ignoring packet");
+        Objective objective;
+        if (packet.getAction() == ObjectiveAction.ADD) {
+            objective = scoreboard.registerNewObjective(packet.getName());
+        } else {
+            objective = scoreboard.getObjective(packet.getName());
+        }
+
+        // matches vanilla
+        if (objective == null) {
             return;
         }
 
-        if ((objective == null || objective.getUpdateType() == UpdateType.REMOVE) && packet.getAction() != ObjectiveAction.REMOVE) {
-            objective = scoreboard.registerNewObjective(packet.getName());
-        }
-
         switch (packet.getAction()) {
-            case ADD, UPDATE -> {
-                objective.setDisplayName(MessageTranslator.convertMessage(packet.getDisplayName()))
-                        .setNumberFormat(packet.getNumberFormat())
-                        .setType(packet.getType().ordinal());
-                if (objective == scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME)) {
-                    // Update the score tag of all players
-                    for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) {
-                        if (entity.isValid()) {
-                            entity.setBelowNameText(objective);
-                        }
-                    }
-                }
-            }
-            case REMOVE -> {
-                scoreboard.unregisterObjective(packet.getName());
-                if (objective != null && objective == scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME)) {
-                    // Clear the score tag from all players
-                    for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) {
-                        // Other places we check for the entity being valid,
-                        // but we must set the below name text as null for all players
-                        // or else PlayerEntity#spawnEntity will find a null objective and not touch EntityData#SCORE_TAG
-                        entity.setBelowNameText(null);
-                    }
-                }
-            }
+            case ADD, UPDATE ->
+                objective.updateProperties(packet.getDisplayName(), packet.getType(), packet.getNumberFormat());
+            case REMOVE -> scoreboard.removeObjective(objective);
         }
 
-        if (objective == null || !objective.isActive()) {
+        // Scoreboard#removeObjective doesn't touch the display slot(s) that were attached to it.
+        // So Objective#hasDisplaySlot will be true as long as it's currently present on the Bedrock client
+        if (!objective.hasDisplaySlot()) {
             return;
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java
index 999edcc8c..3a1ee6373 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetPlayerTeamTranslator.java
@@ -25,23 +25,17 @@
 
 package org.geysermc.geyser.translator.protocol.java.scoreboard;
 
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamAction;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
-import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket;
+import java.util.Arrays;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
 import org.geysermc.geyser.scoreboard.Scoreboard;
 import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
 import org.geysermc.geyser.scoreboard.Team;
-import org.geysermc.geyser.scoreboard.UpdateType;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.translator.text.MessageTranslator;
-
-import java.util.Arrays;
-import java.util.Set;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamAction;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket;
 
 @Translator(packet = ClientboundSetPlayerTeamPacket.class)
 public class JavaSetPlayerTeamTranslator extends PacketTranslator<ClientboundSetPlayerTeamPacket> {
@@ -60,83 +54,45 @@ public class JavaSetPlayerTeamTranslator extends PacketTranslator<ClientboundSet
         int pps = session.getWorldCache().increaseAndGetScoreboardPacketsPerSecond();
 
         Scoreboard scoreboard = session.getWorldCache().getScoreboard();
-        Team team = scoreboard.getTeam(packet.getTeamName());
-        switch (packet.getAction()) {
-            case CREATE -> {
-                team = scoreboard.registerNewTeam(packet.getTeamName(), packet.getPlayers())
-                        .setName(MessageTranslator.convertMessage(packet.getDisplayName()))
-                        .setColor(packet.getColor())
-                        .setNameTagVisibility(packet.getNameTagVisibility())
-                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.locale()))
-                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.locale()));
 
-                if (packet.getPlayers().length != 0) {
-                    if ((team.getNameTagVisibility() != NameTagVisibility.ALWAYS && !team.isVisibleFor(session.getPlayerEntity().getUsername()))
-                            || team.getColor() != TeamColor.RESET
-                            || !team.getCurrentData().getPrefix().isEmpty()
-                            || !team.getCurrentData().getSuffix().isEmpty()) {
-                        // Something is here that would modify entity names
-                        scoreboard.updateEntityNames(team, true);
-                    }
+        if (packet.getAction() == TeamAction.CREATE) {
+            scoreboard.registerNewTeam(
+                packet.getTeamName(),
+                packet.getPlayers(),
+                packet.getDisplayName(),
+                packet.getPrefix(),
+                packet.getSuffix(),
+                packet.getNameTagVisibility(),
+                packet.getColor()
+            );
+        } else {
+            Team team = scoreboard.getTeam(packet.getTeamName());
+            if (team == null) {
+                if (logger.isDebug()) {
+                    logger.debug("Error while translating Team Packet " + packet.getAction()
+                        + "! Scoreboard Team " + packet.getTeamName() + " is not registered."
+                    );
                 }
+                return;
             }
-            case UPDATE -> {
-                if (team == null) {
-                    if (logger.isDebug()) {
-                        logger.debug("Error while translating Team Packet " + packet.getAction()
-                                + "! Scoreboard Team " + packet.getTeamName() + " is not registered."
-                        );
-                    }
-                    return;
-                }
 
-                TeamColor oldColor = team.getColor();
-                NameTagVisibility oldVisibility = team.getNameTagVisibility();
-                String oldPrefix = team.getCurrentData().getPrefix();
-                String oldSuffix = team.getCurrentData().getSuffix();
-
-                team.setName(MessageTranslator.convertMessage(packet.getDisplayName()))
-                        .setColor(packet.getColor())
-                        .setNameTagVisibility(packet.getNameTagVisibility())
-                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.locale()))
-                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.locale()))
-                        .setUpdateType(UpdateType.UPDATE);
-
-                if (oldVisibility != team.getNameTagVisibility()
-                        || oldColor != team.getColor()
-                        || !oldPrefix.equals(team.getCurrentData().getPrefix())
-                        || !oldSuffix.equals(team.getCurrentData().getSuffix())) {
-                    // Update entities attached to this team as something about their nameplates have changed
-                    scoreboard.updateEntityNames(team, false);
+            switch (packet.getAction()) {
+                case UPDATE -> {
+                    team.updateProperties(
+                        packet.getDisplayName(),
+                        packet.getPrefix(),
+                        packet.getSuffix(),
+                        packet.getNameTagVisibility(),
+                        packet.getColor()
+                    );
                 }
+                case ADD_PLAYER -> team.addEntities(packet.getPlayers());
+                case REMOVE_PLAYER -> team.removeEntities(packet.getPlayers());
+                case REMOVE -> scoreboard.removeTeam(packet.getTeamName());
             }
-            case ADD_PLAYER -> {
-                if (team == null) {
-                    if (logger.isDebug()) {
-                        logger.debug("Error while translating Team Packet " + packet.getAction()
-                                + "! Scoreboard Team " + packet.getTeamName() + " is not registered."
-                        );
-                    }
-                    return;
-                }
-                Set<String> added = team.addEntities(packet.getPlayers());
-                scoreboard.updateEntityNames(team, added, true);
-            }
-            case REMOVE_PLAYER -> {
-                if (team == null) {
-                    if (logger.isDebug()) {
-                        logger.debug("Error while translating Team Packet " + packet.getAction()
-                                + "! Scoreboard Team " + packet.getTeamName() + " is not registered."
-                        );
-                    }
-                    return;
-                }
-                Set<String> removed = team.removeEntities(packet.getPlayers());
-                scoreboard.updateEntityNames(null, removed, true);
-            }
-            case REMOVE -> scoreboard.removeTeam(packet.getTeamName());
         }
 
+
         // ScoreboardUpdater will handle it for us if the packets per second
         // (for score and team packets) is higher than the first threshold
         if (pps < ScoreboardUpdater.FIRST_SCORE_PACKETS_PER_SECOND_THRESHOLD) {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java
index d1645b496..989f0f2cb 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetScoreTranslator.java
@@ -25,12 +25,8 @@
 
 package org.geysermc.geyser.translator.protocol.java.scoreboard;
 
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
-import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
-import org.checkerframework.checker.nullness.qual.Nullable;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.scoreboard.Objective;
 import org.geysermc.geyser.scoreboard.Scoreboard;
 import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
@@ -39,6 +35,7 @@ import org.geysermc.geyser.session.cache.WorldCache;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
 
 @Translator(packet = ClientboundSetScorePacket.class)
 public class JavaSetScoreTranslator extends PacketTranslator<ClientboundSetScorePacket> {
@@ -63,16 +60,7 @@ public class JavaSetScoreTranslator extends PacketTranslator<ClientboundSetScore
             }
             return;
         }
-
-        // If this is the objective that is in use to show the below name text, we need to update the player
-        // attached to this score.
-        boolean isBelowName = objective == scoreboard.getObjectiveSlots().get(ScoreboardPosition.BELOW_NAME);
-
         objective.setScore(packet.getOwner(), packet.getValue(), packet.getDisplay(), packet.getNumberFormat());
-        if (isBelowName) {
-            // Update the below name score on this player
-            setBelowName(session, objective, packet.getOwner());
-        }
 
         // ScoreboardUpdater will handle it for us if the packets per second
         // (for score and team packets) is higher than the first threshold
@@ -80,36 +68,4 @@ public class JavaSetScoreTranslator extends PacketTranslator<ClientboundSetScore
             scoreboard.onUpdate();
         }
     }
-
-    /**
-     * @param objective the objective that currently resides on the below name display slot
-     */
-    static void setBelowName(GeyserSession session, Objective objective, String username) {
-        PlayerEntity entity = getOtherPlayerEntity(session, username);
-        if (entity == null) {
-            return;
-        }
-
-        entity.setBelowNameText(objective);
-    }
-
-    private static @Nullable PlayerEntity getOtherPlayerEntity(GeyserSession session, String username) {
-        // We don't care about the session player, because... they're not going to be seeing their own score
-        if (session.getPlayerEntity().getUsername().equals(username)) {
-            return null;
-        }
-
-        for (PlayerEntity entity : session.getEntityCache().getAllPlayerEntities()) {
-            if (entity.getUsername().equals(username)) {
-                if (entity.isValid()) {
-                    return entity;
-                } else {
-                    // The below name text will be applied on spawn
-                    return null;
-                }
-            }
-        }
-
-        return null;
-    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java
index 1932d3e47..eca86ff32 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java
@@ -25,6 +25,8 @@
 
 package org.geysermc.geyser.translator.text;
 
+import java.util.ArrayList;
+import java.util.List;
 import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.JoinConfiguration;
 import net.kyori.adventure.text.ScoreComponent;
@@ -53,12 +55,6 @@ import org.geysermc.mcprotocollib.protocol.data.DefaultComponentSerializer;
 import org.geysermc.mcprotocollib.protocol.data.game.Holder;
 import org.geysermc.mcprotocollib.protocol.data.game.chat.ChatType;
 import org.geysermc.mcprotocollib.protocol.data.game.chat.ChatTypeDecoration;
-import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
-
-import java.util.ArrayList;
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
 
 public class MessageTranslator {
     // These are used for handling the translations of the messages
@@ -71,9 +67,6 @@ public class MessageTranslator {
     private static final LegacyComponentSerializer BEDROCK_SERIALIZER;
     private static final String BEDROCK_COLORS;
 
-    // Store team colors for player names
-    private static final Map<TeamColor, String> TEAM_COLORS = new EnumMap<>(TeamColor.class);
-
     // Legacy formatting character
     private static final String BASE = "\u00a7";
 
@@ -81,31 +74,6 @@ public class MessageTranslator {
     private static final String RESET = BASE + "r";
 
     static {
-        TEAM_COLORS.put(TeamColor.RESET, RESET);
-
-        TEAM_COLORS.put(TeamColor.BLACK, BASE + "0");
-        TEAM_COLORS.put(TeamColor.DARK_BLUE, BASE + "1");
-        TEAM_COLORS.put(TeamColor.DARK_GREEN, BASE + "2");
-        TEAM_COLORS.put(TeamColor.DARK_AQUA, BASE + "3");
-        TEAM_COLORS.put(TeamColor.DARK_RED, BASE + "4");
-        TEAM_COLORS.put(TeamColor.DARK_PURPLE, BASE + "5");
-        TEAM_COLORS.put(TeamColor.GOLD, BASE + "6");
-        TEAM_COLORS.put(TeamColor.GRAY, BASE + "7");
-        TEAM_COLORS.put(TeamColor.DARK_GRAY, BASE + "8");
-        TEAM_COLORS.put(TeamColor.BLUE, BASE + "9");
-        TEAM_COLORS.put(TeamColor.GREEN, BASE + "a");
-        TEAM_COLORS.put(TeamColor.AQUA, BASE + "b");
-        TEAM_COLORS.put(TeamColor.RED, BASE + "c");
-        TEAM_COLORS.put(TeamColor.LIGHT_PURPLE, BASE + "d");
-        TEAM_COLORS.put(TeamColor.YELLOW, BASE + "e");
-        TEAM_COLORS.put(TeamColor.WHITE, BASE + "f");
-
-        // Formats, not colors
-        TEAM_COLORS.put(TeamColor.OBFUSCATED, BASE + "k");
-        TEAM_COLORS.put(TeamColor.BOLD, BASE + "l");
-        TEAM_COLORS.put(TeamColor.STRIKETHROUGH, BASE + "m");
-        TEAM_COLORS.put(TeamColor.ITALIC, BASE + "o");
-
         // Temporary fix for https://github.com/KyoriPowered/adventure/issues/447 - TODO resolve properly
         GsonComponentSerializer source = DefaultComponentSerializer.get()
                 .toBuilder()
@@ -157,13 +125,31 @@ public class MessageTranslator {
     }
 
     /**
-     * Convert a Java message to the legacy format ready for bedrock
+     * Convert a Java message to the legacy format ready for bedrock. Unlike
+     * {@link #convertMessageRaw(Component, String)} this adds a leading color reset. In Bedrock
+     * some places have build-in colors.
      *
      * @param message Java message
      * @param locale Locale to use for translation strings
      * @return Parsed and formatted message for bedrock
      */
     public static String convertMessage(Component message, String locale) {
+        return convertMessage(message, locale, true);
+    }
+
+    /**
+     * Convert a Java message to the legacy format ready for bedrock. Unlike {@link #convertMessage(Component, String)}
+     * this version does not add a leading color reset. In Bedrock some places have build-in colors.
+     *
+     * @param message Java message
+     * @param locale Locale to use for translation strings
+     * @return Parsed and formatted message for bedrock
+     */
+    public static String convertMessageRaw(Component message, String locale) {
+        return convertMessage(message, locale, false);
+    }
+
+    private static String convertMessage(Component message, String locale, boolean addLeadingResetFormat) {
         try {
             // Translate any components that require it
             message = RENDERER.render(message, locale);
@@ -172,7 +158,7 @@ public class MessageTranslator {
 
             StringBuilder finalLegacy = new StringBuilder();
             char[] legacyChars = legacy.toCharArray();
-            boolean lastFormatReset = false;
+            boolean lastFormatReset = !addLeadingResetFormat;
             for (int i = 0; i < legacyChars.length; i++) {
                 char legacyChar = legacyChars[i];
                 if (legacyChar != ChatColor.ESCAPE || i >= legacyChars.length - 1) {
@@ -185,7 +171,7 @@ public class MessageTranslator {
 
                 char next = legacyChars[++i];
                 if (BEDROCK_COLORS.indexOf(next) != -1) {
-                    // Append this color code, as well as a necessary reset code
+                    // Unlike Java Edition, the ChatFormatting is not reset when a ChatColor is added
                     if (!lastFormatReset) {
                         finalLegacy.append(RESET);
                     }
@@ -378,16 +364,6 @@ public class MessageTranslator {
         session.sendUpstreamPacket(textPacket);
     }
 
-    /**
-     * Convert a team color to a chat color
-     *
-     * @param teamColor Color or format to convert
-     * @return The chat color character
-     */
-    public static String toChatColor(TeamColor teamColor) {
-        return TEAM_COLORS.getOrDefault(teamColor, "");
-    }
-
     /**
      * Checks if the given message is over 256 characters (Java edition server chat limit) and sends a message to the user if it is
      *
diff --git a/core/src/main/java/org/geysermc/geyser/util/ChunkUtils.java b/core/src/main/java/org/geysermc/geyser/util/ChunkUtils.java
index 288b425ba..96471a2ce 100644
--- a/core/src/main/java/org/geysermc/geyser/util/ChunkUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/ChunkUtils.java
@@ -167,7 +167,7 @@ public class ChunkUtils {
             byteBuf.readBytes(payload);
 
             LevelChunkPacket data = new LevelChunkPacket();
-            data.setDimension(DimensionUtils.javaToBedrock(session.getBedrockDimension()));
+            data.setDimension(session.getBedrockDimension().bedrockId());
             data.setChunkX(chunkX);
             data.setChunkZ(chunkZ);
             data.setSubChunksLength(0);
@@ -207,13 +207,6 @@ public class ChunkUtils {
         int minY = dimension.minY();
         int maxY = dimension.maxY();
 
-        if (minY % 16 != 0) {
-            throw new RuntimeException("Minimum Y must be a multiple of 16!");
-        }
-        if (maxY % 16 != 0) {
-            throw new RuntimeException("Maximum Y must be a multiple of 16!");
-        }
-
         BedrockDimension bedrockDimension = session.getBedrockDimension();
         // Yell in the console if the world height is too height in the current scenario
         // The constraints change depending on if the player is in the overworld or not, and if experimental height is enabled
diff --git a/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java b/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java
index 8dc94a165..b4fd6b924 100644
--- a/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java
@@ -44,17 +44,8 @@ import java.util.Set;
 
 public class DimensionUtils {
 
-    // Changes if the above-bedrock Nether building workaround is applied
-    private static int BEDROCK_NETHER_ID = 1;
-
     public static final String BEDROCK_FOG_HELL = "minecraft:fog_hell";
 
-    public static final String NETHER_IDENTIFIER = "minecraft:the_nether";
-
-    private static final int BEDROCK_OVERWORLD_ID = 0;
-    private static final int BEDROCK_DEFAULT_NETHER_ID = 1;
-    private static final int BEDROCK_END_ID = 2;
-
     public static void switchDimension(GeyserSession session, JavaDimension javaDimension) {
         switchDimension(session, javaDimension, javaDimension.bedrockId());
     }
@@ -95,7 +86,7 @@ public class DimensionUtils {
         // If the bedrock nether height workaround is enabled, meaning the client is told it's in the end dimension,
         // we check if the player is entering the nether and apply the nether fog to fake the fact that the client
         // thinks they are in the end dimension.
-        if (isCustomBedrockNetherId()) {
+        if (BedrockDimension.isCustomBedrockNetherId()) {
             if (javaDimension.isNetherLike()) {
                 session.camera().sendFog(BEDROCK_FOG_HELL);
             } else if (previousDimension != null && previousDimension.isNetherLike()) {
@@ -168,22 +159,12 @@ public class DimensionUtils {
 
     public static void setBedrockDimension(GeyserSession session, int bedrockDimension) {
         session.setBedrockDimension(switch (bedrockDimension) {
-            case BEDROCK_END_ID -> BedrockDimension.THE_END;
-            case BEDROCK_DEFAULT_NETHER_ID -> BedrockDimension.THE_NETHER; // JavaDimension *should* be set to BEDROCK_END_ID if the Nether workaround is enabled.
-            default -> BedrockDimension.OVERWORLD;
+            case BedrockDimension.END_ID -> BedrockDimension.THE_END;
+            case BedrockDimension.DEFAULT_NETHER_ID -> BedrockDimension.THE_NETHER; // JavaDimension *should* be set to BEDROCK_END_ID if the Nether workaround is enabled.
+            default -> session.getBedrockOverworldDimension();
         });
     }
 
-    public static int javaToBedrock(BedrockDimension dimension) {
-        if (dimension == BedrockDimension.THE_NETHER) {
-            return BEDROCK_NETHER_ID;
-        } else if (dimension == BedrockDimension.THE_END) {
-            return BEDROCK_END_ID;
-        } else {
-            return BEDROCK_OVERWORLD_ID;
-        }
-    }
-
     /**
      * Map the Java edition dimension IDs to Bedrock edition
      *
@@ -192,9 +173,9 @@ public class DimensionUtils {
      */
     public static int javaToBedrock(String javaDimension) {
         return switch (javaDimension) {
-            case NETHER_IDENTIFIER -> BEDROCK_NETHER_ID;
-            case "minecraft:the_end" -> 2;
-            default -> 0;
+            case BedrockDimension.NETHER_IDENTIFIER -> BedrockDimension.BEDROCK_NETHER_ID;
+            case "minecraft:the_end" -> BedrockDimension.END_ID;
+            default -> BedrockDimension.OVERWORLD_ID;
         };
     }
 
@@ -204,22 +185,11 @@ public class DimensionUtils {
     public static int javaToBedrock(GeyserSession session) {
         JavaDimension dimension = session.getDimensionType();
         if (dimension == null) {
-            return BEDROCK_OVERWORLD_ID;
+            return BedrockDimension.OVERWORLD_ID;
         }
         return dimension.bedrockId();
     }
 
-    /**
-     * The Nether dimension in Bedrock does not permit building above Y128 - the Bedrock above the dimension.
-     * This workaround sets the Nether as the End dimension to ignore this limit.
-     *
-     * @param isAboveNetherBedrockBuilding true if we should apply The End workaround
-     */
-    public static void changeBedrockNetherId(boolean isAboveNetherBedrockBuilding) {
-        // Change dimension ID to the End to allow for building above Bedrock
-        BEDROCK_NETHER_ID = isAboveNetherBedrockBuilding ? BEDROCK_END_ID : BEDROCK_DEFAULT_NETHER_ID;
-    }
-
     /**
      * Gets the fake, temporary dimension we send clients to so we aren't switching to the same dimension without an additional
      * dimension switch.
@@ -229,16 +199,13 @@ public class DimensionUtils {
      * @return the Bedrock fake dimension to transfer to
      */
     public static int getTemporaryDimension(int currentBedrockDimension, int newBedrockDimension) {
-        if (isCustomBedrockNetherId()) {
+        if (BedrockDimension.isCustomBedrockNetherId()) {
             // Prevents rare instances of Bedrock locking up
-            return newBedrockDimension == BEDROCK_END_ID ? BEDROCK_OVERWORLD_ID : BEDROCK_END_ID;
+            return newBedrockDimension == BedrockDimension.END_ID ? BedrockDimension.OVERWORLD_ID : BedrockDimension.END_ID;
         }
         // Check current Bedrock dimension and not just the Java dimension.
         // Fixes rare instances like https://github.com/GeyserMC/Geyser/issues/3161
-        return currentBedrockDimension == BEDROCK_OVERWORLD_ID ? BEDROCK_DEFAULT_NETHER_ID : BEDROCK_OVERWORLD_ID;
+        return currentBedrockDimension == BedrockDimension.OVERWORLD_ID ? BedrockDimension.DEFAULT_NETHER_ID : BedrockDimension.OVERWORLD_ID;
     }
 
-    public static boolean isCustomBedrockNetherId() {
-        return BEDROCK_NETHER_ID == BEDROCK_END_ID;
-    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java b/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java
index 53aefde1e..8e5a57fae 100644
--- a/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/EntityUtils.java
@@ -25,6 +25,10 @@
 
 package org.geysermc.geyser.util;
 
+import java.util.Locale;
+import net.kyori.adventure.key.Key;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.cloudburstmc.math.vector.Vector3f;
 import org.cloudburstmc.protocol.bedrock.data.GameType;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
@@ -38,13 +42,13 @@ import org.geysermc.geyser.entity.type.living.animal.AnimalEntity;
 import org.geysermc.geyser.entity.type.living.animal.horse.CamelEntity;
 import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.item.Items;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.Effect;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.player.GameMode;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.player.Hand;
 import org.geysermc.mcprotocollib.protocol.data.game.entity.type.EntityType;
 
-import java.util.Locale;
-
 public final class EntityUtils {
     /**
      * A constant array of the two hands that a player can interact with an entity.
@@ -290,6 +294,32 @@ public final class EntityUtils {
         };
     }
 
+    private static String translatedEntityName(@NonNull String namespace, @NonNull String name, @NonNull GeyserSession session) {
+        // MinecraftLocale would otherwise invoke getBootstrap (which doesn't exist) and create some folders,
+        // so use the default fallback value as used in Minecraft Java
+        if (EnvironmentUtils.isUnitTesting) {
+            return "entity." + namespace + "." + name;
+        }
+        return MinecraftLocale.getLocaleString("entity." + namespace + "." + name, session.locale());
+    }
+
+    public static String translatedEntityName(@NonNull Key type, @NonNull GeyserSession session) {
+        return translatedEntityName(type.namespace(), type.value(), session);
+    }
+
+    public static String translatedEntityName(@Nullable EntityType type, @NonNull GeyserSession session) {
+        if (type == EntityType.PLAYER) {
+            return "Player"; // the player's name is always shown instead
+        }
+        // default fallback value as used in Minecraft Java
+        if (type == null) {
+            return "entity.unregistered_sadface";
+        }
+        // this works at least with all 1.20.5 entities, except the killer bunny since that's not an entity type.
+        String typeName = type.name().toLowerCase(Locale.ROOT);
+        return translatedEntityName("minecraft", typeName, session);
+    }
+
     private EntityUtils() {
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/util/EnvironmentUtils.java b/core/src/main/java/org/geysermc/geyser/util/EnvironmentUtils.java
new file mode 100644
index 000000000..909398bf4
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/util/EnvironmentUtils.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.util;
+
+public final class EnvironmentUtils {
+    public static final boolean isUnitTesting = isUnitTesting();
+
+    private EnvironmentUtils() {}
+
+    private static boolean isUnitTesting() {
+        for (StackTraceElement element : Thread.currentThread().getStackTrace()) {
+            if (element.getClassName().startsWith("org.junit.")) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java
new file mode 100644
index 000000000..523e4dca2
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/NameVisibilityScoreboardTest.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockAndAddPlayerEntity;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import net.kyori.adventure.text.Component;
+import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
+import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetPlayerTeamTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.CollisionRule;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket;
+import org.junit.jupiter.api.Test;
+
+public class NameVisibilityScoreboardTest {
+    @Test
+    void playerVisibilityNever() {
+        mockContextScoreboard(context -> {
+            var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team1",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.NEVER,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"player1"}
+                )
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "");
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void playerVisibilityHideForOtherTeam() {
+        mockContextScoreboard(context -> {
+            var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team1",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.HIDE_FOR_OTHER_TEAMS,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"player1"}
+                )
+            );
+            // only hidden if session player (Tim203) is in a team as well
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            // create another team and add Tim203 to it
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team2",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.NEVER,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"Tim203"}
+                )
+            );
+            // Tim203 is now in another team, so it should be hidden
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            // add Tim203 to same team as player1, score should be visible again
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("team1", TeamAction.ADD_PLAYER, new String[]{"Tim203"})
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix");
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void playerVisibilityHideForOwnTeam() {
+        mockContextScoreboard(context -> {
+            var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team1",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.HIDE_FOR_OWN_TEAM,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"player1"}
+                )
+            );
+            // Tim203 is not in a team (let alone the same team), so should be visible
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            // Tim203 is now in the same team as player1, so should be hidden
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("team1", TeamAction.ADD_PLAYER, new String[]{"Tim203"})
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            // create another team and add Tim203 to there, score should be visible again
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team2",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.NEVER,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"Tim203"}
+                )
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix");
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void playerVisibilityAlways() {
+        mockContextScoreboard(context -> {
+            var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team1",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"player1"}
+                )
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.NAME, "§4prefix§r§4player1§r§4suffix");
+                return packet;
+            }, context);
+
+            // adding self to another team shouldn't make a difference
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team2",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{"Tim203"}
+                )
+            );
+            assertNoNextPacket(context);
+
+            // adding self to player1 team shouldn't matter
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("team1", TeamAction.ADD_PLAYER, new String[]{"Tim203"})
+            );
+            assertNoNextPacket(context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/ScoreboardIssueTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/ScoreboardIssueTests.java
new file mode 100644
index 000000000..1ec245007
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/ScoreboardIssueTests.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacketType;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.cloudburstmc.protocol.bedrock.packet.AddEntityPacket;
+import org.cloudburstmc.protocol.bedrock.packet.RemoveEntityPacket;
+import org.geysermc.geyser.entity.type.living.monster.EnderDragonPartEntity;
+import org.geysermc.geyser.session.cache.EntityCache;
+import org.geysermc.geyser.translator.protocol.java.entity.JavaRemoveEntitiesTranslator;
+import org.geysermc.geyser.translator.protocol.java.entity.spawn.JavaAddExperienceOrbTranslator;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.entity.ClientboundRemoveEntitiesPacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.entity.spawn.ClientboundAddExperienceOrbPacket;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that don't fit in a larger system (e.g. sidebar objective) that were reported on GitHub
+ */
+public class ScoreboardIssueTests {
+    /**
+     * Test for <a href="https://github.com/GeyserMC/Geyser/issues/5075">#5075</a>
+     */
+    @Test
+    void entityWithoutUuid() {
+        // experience orbs are the only known entities without an uuid, see Entity#teamIdentifier for more info
+        mockContextScoreboard(context -> {
+            var addExperienceOrbTranslator = new JavaAddExperienceOrbTranslator();
+            var removeEntitiesTranslator = new JavaRemoveEntitiesTranslator();
+
+            // Entity#teamIdentifier used to throw because it returned uuid.toString where uuid could be null.
+            // this would result in both EntityCache#spawnEntity and EntityCache#removeEntity throwing an exception,
+            // because the entity would be registered and deregistered to the scoreboard.
+            assertDoesNotThrow(() -> {
+                context.translate(addExperienceOrbTranslator, new ClientboundAddExperienceOrbPacket(2, 0, 0, 0, 1));
+
+                String displayName = context.mockOrSpy(EntityCache.class).getEntityByJavaId(2).getDisplayName();
+                assertEquals("entity.minecraft.experience_orb", displayName);
+
+                context.translate(removeEntitiesTranslator, new ClientboundRemoveEntitiesPacket(new int[] { 2 }));
+            });
+
+            // we know that spawning and removing the entity should be fine
+            assertNextPacketType(context, AddEntityPacket.class);
+            assertNextPacketType(context, RemoveEntityPacket.class);
+        });
+    }
+
+    /**
+     * Test for <a href="https://github.com/GeyserMC/Geyser/issues/5078">#5078</a>
+     */
+    @Test
+    void entityWithoutType() {
+        // dragon entity parts are an entity in Geyser, but do not have an entity type
+        mockContextScoreboard(context -> {
+            // EntityUtils#translatedEntityName used to not take null EntityType's into account,
+            // so it used to throw an exception
+            assertDoesNotThrow(() -> {
+                // dragon entity parts are not spawned using a packet, so we manually create an instance
+                var dragonHeadPart = new EnderDragonPartEntity(context.session(), 2, 2, 1, 1);
+
+                String displayName = dragonHeadPart.getDisplayName();
+                assertEquals("entity.unregistered_sadface", displayName);
+            });
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java
new file mode 100644
index 000000000..5d8d8309f
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/belowname/BasicBelownameScoreboardTests.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.belowname;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockAndAddPlayerEntity;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
+import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
+import org.junit.jupiter.api.Test;
+
+public class BasicBelownameScoreboardTests {
+    @Test
+    void displayWithNoPlayersAndRemove() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective")
+            );
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "")
+            );
+            assertNoNextPacket(context);
+        });
+    }
+
+    @Test
+    void displayColorWithOnePlayer() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective", NamedTextColor.BLUE),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "0 §r§9objective");
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void displayWithOnePlayerAndRemove() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "0 §robjective");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "");
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void overrideAndRemove() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            mockAndAddPlayerEntity(context, "player1", 2);
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective1",
+                    ObjectiveAction.ADD,
+                    Component.text("objective1"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective2",
+                    ObjectiveAction.ADD,
+                    Component.text("objective2"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective2")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "0 §robjective2");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "objective1")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "");
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "0 §robjective1");
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.BELOW_NAME, "")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetEntityDataPacket();
+                packet.setRuntimeEntityId(2);
+                packet.getMetadata().put(EntityDataTypes.SCORE, "");
+                return packet;
+            }, context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java
new file mode 100644
index 000000000..a3d4ad671
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/playerlist/BasicPlayerlistScoreboardTests.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.playerlist;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.Style;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
+import org.junit.jupiter.api.Test;
+
+/*
+Identical to sidebar
+ */
+public class BasicPlayerlistScoreboardTests {
+    @Test
+    void display() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("list");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void displayNameColors() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective", Style.style(NamedTextColor.AQUA, TextDecoration.BOLD)),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("§b§lobjective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("list");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void overrideWithOneScore() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+            var setScoreTranslator = new JavaSetScoreTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective1",
+                    ObjectiveAction.ADD,
+                    Component.text("objective1"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective2",
+                    ObjectiveAction.ADD,
+                    Component.text("objective2"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective1", 1));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective2", 2));
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective2")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective2");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("list");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                // session player name is Tim203
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 2, ScoreInfo.ScorerType.PLAYER, 1)));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective1")
+            );
+            assertNextPacket(() -> {
+                var packet = new RemoveObjectivePacket();
+                packet.setObjectiveId("0");
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("2");
+                packet.setDisplayName("objective1");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("list");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                // session player name is Tim203
+                packet.setInfos(List.of(new ScoreInfo(3, "2", 1, ScoreInfo.ScorerType.PLAYER, 1)));
+                return packet;
+            }, context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/server/CubecraftScoreboardTest.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/server/CubecraftScoreboardTest.java
new file mode 100644
index 000000000..dd693022c
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/server/CubecraftScoreboardTest.java
@@ -0,0 +1,756 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.server;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockAndAddPlayerEntity;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.Style;
+import net.kyori.adventure.text.format.TextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.cloudburstmc.protocol.bedrock.data.entity.EntityDataTypes;
+import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetEntityDataPacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetPlayerTeamTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.CollisionRule;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
+import org.junit.jupiter.api.Test;
+
+public class CubecraftScoreboardTest {
+    @Test
+    void test() {
+        mockContextScoreboard(context -> {
+            var setTeamTranslator = new JavaSetPlayerTeamTranslator();
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+            var setScoreTranslator = new JavaSetScoreTranslator();
+
+            // unused
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("SB_NoName", Component.text("SB_NoName"), Component.empty(), Component.empty(), true, true, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.RESET, new String[0]));
+            assertNoNextPacket(context);
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "sidebar",
+                    ObjectiveAction.ADD,
+                    Component.text("sidebar"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "sidebar")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("sidebar");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+
+
+            // Now they're going to create a bunch of teams and add players to those teams in a very inefficient way.
+            // Presumably this is a leftover from an old system, as these don't seem to do anything but hide their nametags.
+            // For which you could just use a single team.
+
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0]));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", Component.text("2i|1"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.DARK_GRAY));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "A_Player" }));
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0]));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", Component.text("1y|11"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", TeamAction.ADD_PLAYER, new String[] { "B_Player" }));
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "C_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "D_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1y|11", TeamAction.ADD_PLAYER, new String[] { "E_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "F_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "G_Player" }));
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0]));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", Component.text("2e|3"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.BLUE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", TeamAction.ADD_PLAYER, new String[] { "H_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "I_Player" }));
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0]));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", Component.text("22|9"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("22|9", TeamAction.ADD_PLAYER, new String[] { "J_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "K_Player" }));
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0]));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", Component.text("26|7"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.AQUA));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("26|7", TeamAction.ADD_PLAYER, new String[] { "L_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2e|3", TeamAction.ADD_PLAYER, new String[] { "M_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "N_Player" }));
+
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.RESET, new String[0]));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), true, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, true, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, false, NameTagVisibility.ALWAYS, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.ALWAYS, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", Component.text("1u|13"), Component.empty(), Component.empty(), false, false, NameTagVisibility.NEVER, CollisionRule.NEVER, TeamColor.LIGHT_PURPLE));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("1u|13", TeamAction.ADD_PLAYER, new String[] { "O_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "P_Player" }));
+            context.translate(setTeamTranslator, new ClientboundSetPlayerTeamPacket("2i|1", TeamAction.ADD_PLAYER, new String[] { "Q_Player" }));
+
+            assertNoNextPacket(context);
+
+
+            // Now that those teams are created and people added to it, they set the final sidebar name and add the lines to it.
+            // They're also not doing this efficiently, because they don't add the players when the team is created.
+            // Instead, they send an additional packet.
+
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "sidebar",
+                    ObjectiveAction.UPDATE,
+                    Component.empty()
+                        .append(Component.text(
+                            "CubeCraft", Style.style(NamedTextColor.WHITE, TextDecoration.BOLD))),
+                    ScoreType.INTEGER,
+                    null));
+            assertNextPacket(
+                () -> {
+                    var packet = new RemoveObjectivePacket();
+                    packet.setObjectiveId("0");
+                    return packet;
+                },
+                context);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetDisplayObjectivePacket();
+                    packet.setObjectiveId("0");
+                    packet.setDisplayName("§f§lCubeCraft");
+                    packet.setCriteria("dummy");
+                    packet.setDisplaySlot("sidebar");
+                    packet.setSortOrder(1);
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-0",
+                    Component.text("SB_l-0"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-0", TeamAction.ADD_PLAYER, new String[] {"§0§0"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-0",
+                    Component.text("SB_l-0"),
+                    Component.empty().append(Component.text("", Style.style(NamedTextColor.BLACK))),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§0", "sidebar", 10));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(1, "0", 10, "§r§0§0§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-1",
+                    Component.text("SB_l-1"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-1", TeamAction.ADD_PLAYER, new String[] {"§0§1"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-1",
+                    Component.text("SB_l-1"),
+                    Component.empty()
+                        .append(Component.textOfChildren(
+                            Component.text("User: ", TextColor.color(0x3aa9ff)),
+                            Component.text("Tim203", NamedTextColor.WHITE))),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§1", "sidebar", 9));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(2, "0", 9, "§bUser: §r§fTim203§r§0§1§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-2",
+                    Component.text("SB_l-2"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-2", TeamAction.ADD_PLAYER, new String[] {"§0§2"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-2",
+                    Component.text("SB_l-2"),
+                    Component.empty()
+                        .append(Component.textOfChildren(
+                            Component.text("Rank: ", TextColor.color(0x3aa9ff)),
+                            Component.text("\uE1AB ", NamedTextColor.WHITE))),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§2", "sidebar", 8));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(3, "0", 8, "§bRank: §r§f\uE1AB §r§0§2§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-3",
+                    Component.text("SB_l-3"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-3", TeamAction.ADD_PLAYER, new String[] {"§0§3"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-3",
+                    Component.text("SB_l-3"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§3", "sidebar", 7));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(4, "0", 7, "§r§0§3§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-4",
+                    Component.text("SB_l-4"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-4", TeamAction.ADD_PLAYER, new String[] {"§0§4"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-4",
+                    Component.text("SB_l-4"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§4", "sidebar", 6));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(5, "0", 6, "§r§0§4§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-5",
+                    Component.text("SB_l-5"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-5", TeamAction.ADD_PLAYER, new String[] {"§0§5"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-5",
+                    Component.text("SB_l-5"),
+                    Component.empty().append(Component.text("", NamedTextColor.DARK_BLUE)),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§5", "sidebar", 5));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(6, "0", 5, "§r§0§5§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-6",
+                    Component.text("SB_l-6"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-6", TeamAction.ADD_PLAYER, new String[] {"§0§6"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-6",
+                    Component.text("SB_l-6"),
+                    Component.empty()
+                        .append(Component.textOfChildren(
+                            Component.text("Lobby: ", TextColor.color(0x3aa9ff)),
+                            Component.text("EU #10", NamedTextColor.WHITE))),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§6", "sidebar", 4));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(7, "0", 4, "§bLobby: §r§fEU #10§r§0§6§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-7",
+                    Component.text("SB_l-7"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-7", TeamAction.ADD_PLAYER, new String[] {"§0§7"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-7",
+                    Component.text("SB_l-7"),
+                    Component.empty()
+                        .append(Component.textOfChildren(
+                            Component.text("Players: ", TextColor.color(0x3aa9ff)),
+                            Component.text("783", NamedTextColor.WHITE))),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§7", "sidebar", 3));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(8, "0", 3, "§bPlayers: §r§f783§r§0§7§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-8",
+                    Component.text("SB_l-8"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-8", TeamAction.ADD_PLAYER, new String[] {"§0§8"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-8",
+                    Component.text("SB_l-8"),
+                    Component.empty().append(Component.text("", NamedTextColor.DARK_GREEN)),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§8", "sidebar", 2));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(9, "0", 2, "§r§0§8§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-9",
+                    Component.text("SB_l-9"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-9", TeamAction.ADD_PLAYER, new String[] {"§0§9"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-9",
+                    Component.text("SB_l-9"),
+                    Component.empty().append(Component.text("24/09/24 (g2208)", TextColor.color(0x777777))),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§9", "sidebar", 1));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(10, "0", 1, "§824/09/24 (g2208)§r§0§9§r")));
+                    return packet;
+                },
+                context);
+
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-10",
+                    Component.text("SB_l-10"),
+                    Component.empty(),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET,
+                    new String[0]));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket("SB_l-10", TeamAction.ADD_PLAYER, new String[] {"§0§a"}));
+            context.translate(
+                setTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "SB_l-10",
+                    Component.text("SB_l-10"),
+                    Component.empty().append(Component.text("play.cubecraft.net", NamedTextColor.GOLD)),
+                    Component.empty(),
+                    true,
+                    true,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.ALWAYS,
+                    TeamColor.RESET));
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("§0§a", "sidebar", 0));
+            assertNextPacket(
+                () -> {
+                    var packet = new SetScorePacket();
+                    packet.setAction(SetScorePacket.Action.SET);
+                    packet.setInfos(List.of(new ScoreInfo(11, "0", 0, "§6play.cubecraft.net§r§0§a§r")));
+                    return packet;
+                },
+                context);
+
+            // after this we get a ClientboundPlayerInfoUpdatePacket with the action UPDATE_DISPLAY_NAME,
+            // but that one is only shown in the tablist so we don't have to handle that.
+            // And after that we get each player's ClientboundPlayerInfoUpdatePacket with also a UPDATE_DISPLAY_NAME,
+            // which is also not interesting for us.
+            // CubeCraft seems to use two armor stands per player: 1 for the rank badge and 1 for the player name.
+            // So the only thing we have to verify is that the nametag is hidden
+
+            mockAndAddPlayerEntity(context, "A_Player", 2);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(2);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "B_Player", 3);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(3);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "E_Player", 4);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(4);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "H_Player", 5);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(5);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "J_Player", 6);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(6);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "K_Player", 7);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(7);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "L_Player", 8);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(8);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+
+            mockAndAddPlayerEntity(context, "O_Player", 9);
+            assertNextPacket(
+                () -> {
+                    var packet = new SetEntityDataPacket();
+                    packet.setRuntimeEntityId(9);
+                    packet.getMetadata().put(EntityDataTypes.NAME, "");
+                    return packet;
+                },
+                context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java
new file mode 100644
index 000000000..b3999303e
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/BasicSidebarScoreboardTests.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.sidebar;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.Style;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.cloudburstmc.protocol.bedrock.packet.RemoveObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
+import org.junit.jupiter.api.Test;
+
+/*
+Identical to playerlist
+ */
+public class BasicSidebarScoreboardTests {
+    @Test
+    void displayAndRemove() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("list");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.PLAYER_LIST, "")
+            );
+            assertNextPacket(() -> {
+                var packet = new RemoveObjectivePacket();
+                packet.setObjectiveId("0");
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void displayNameColors() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective", Style.style(NamedTextColor.AQUA, TextDecoration.BOLD)),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("§b§lobjective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void override() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+            var setScoreTranslator = new JavaSetScoreTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective1",
+                    ObjectiveAction.ADD,
+                    Component.text("objective1"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective2",
+                    ObjectiveAction.ADD,
+                    Component.text("objective2"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective1", 1));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("Tim203", "objective2", 2)); 
+            assertNoNextPacket(context);
+
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective2")
+            );
+
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective2");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "Tim203")));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective1")
+            );
+
+            assertNextPacket(() -> {
+                var packet = new RemoveObjectivePacket();
+                packet.setObjectiveId("0");
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("2");
+                packet.setDisplayName("objective1");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(3, "2", 1, "Tim203")));
+                return packet;
+            }, context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java
new file mode 100644
index 000000000..3e0be1c02
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/OrderAndLimitSidebarScoreboardTests.java
@@ -0,0 +1,533 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.sidebar;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaResetScorePacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetPlayerTeamTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.CollisionRule;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.NameTagVisibility;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.TeamColor;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundResetScorePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
+import org.junit.jupiter.api.Test;
+
+public class OrderAndLimitSidebarScoreboardTests {
+    @Test
+    void aboveDisplayLimit() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+            var setScoreTranslator = new JavaSetScoreTranslator();
+            var resetScoreTranslator = new JavaResetScorePacket();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+
+            // some are in an odd order to make sure that there is no bias for which score is send first,
+            // and to make sure that the score value also doesn't influence the order
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("a", "objective", 1));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("b", "objective", 2));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("c", "objective", 3));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("d", "objective", 5));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("e", "objective", 4));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("f", "objective", 6));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("g", "objective", 9));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("h", "objective", 8));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("i", "objective", 7));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("p", "objective", 10));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("o", "objective", 11));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("n", "objective", 12));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("m", "objective", 13));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("k", "objective", 14));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("l", "objective", 15));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("j", "objective", 16));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("q", "objective", 17));
+            assertNoNextPacket(context);
+
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(1, "0", 17, "q"),
+                    new ScoreInfo(2, "0", 16, "j"),
+                    new ScoreInfo(3, "0", 15, "l"),
+                    new ScoreInfo(4, "0", 14, "k"),
+                    new ScoreInfo(5, "0", 13, "m"),
+                    new ScoreInfo(6, "0", 12, "n"),
+                    new ScoreInfo(7, "0", 11, "o"),
+                    new ScoreInfo(8, "0", 10, "p"),
+                    new ScoreInfo(9, "0", 9, "g"),
+                    new ScoreInfo(10, "0", 8, "h"),
+                    new ScoreInfo(11, "0", 7, "i"),
+                    new ScoreInfo(12, "0", 6, "f"),
+                    new ScoreInfo(13, "0", 5, "d"),
+                    new ScoreInfo(14, "0", 4, "e"),
+                    new ScoreInfo(15, "0", 3, "c")
+                ));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            // remove a score
+            context.translate(
+                resetScoreTranslator,
+                new ClientboundResetScorePacket("m", "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(new ScoreInfo(5, "0", 13, "m")));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b")));
+                return packet;
+            }, context);
+
+            // add a score
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("aa", "objective", 13)
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b")));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(17, "0", 13, "aa")));
+                return packet;
+            }, context);
+
+            // add score with same score value (after)
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("ga", "objective", 9)
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(15, "0", 3, "c"),
+                    new ScoreInfo(9, "0", 9, "§0§rg")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(9, "0", 9, "§0§rg"),
+                    new ScoreInfo(18, "0", 9, "§1§rga")
+                ));
+                return packet;
+            }, context);
+
+            // add another score with same score value (before all)
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("ag", "objective", 9)
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(14, "0", 4, "e"),
+                    new ScoreInfo(9, "0", 9, "§1§rg"),
+                    new ScoreInfo(18, "0", 9, "§2§rga")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(19, "0", 9, "§0§rag"),
+                    new ScoreInfo(9, "0", 9, "§1§rg"),
+                    new ScoreInfo(18, "0", 9, "§2§rga")
+                ));
+                return packet;
+            }, context);
+
+            // remove score with same value
+            context.translate(
+                resetScoreTranslator,
+                new ClientboundResetScorePacket("g", "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(9, "0", 9, "§1§rg"),
+                    new ScoreInfo(18, "0", 9, "§1§rga")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(18, "0", 9, "§1§rga"),
+                    new ScoreInfo(20, "0", 4, "e")
+                ));
+                return packet;
+            }, context);
+
+            // remove the other score with the same value
+            context.translate(
+                resetScoreTranslator,
+                new ClientboundResetScorePacket("ga", "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(18, "0", 9, "§1§rga"),
+                    new ScoreInfo(19, "0", 9, "ag")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(19, "0", 9, "ag"),
+                    new ScoreInfo(21, "0", 3, "c")
+                ));
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void aboveDisplayLimitWithTeam() {
+        mockContextScoreboard(context -> {
+            var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+            var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+            var setScoreTranslator = new JavaSetScoreTranslator();
+            var resetScoreTranslator = new JavaResetScorePacket();
+            var setPlayerTeamTranslator = new JavaSetPlayerTeamTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+
+            // some are in an odd order to make sure that there is no bias for which score is send first,
+            // and to make sure that the score value also doesn't influence the order
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("a", "objective", 1));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("b", "objective", 2));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("c", "objective", 3));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("d", "objective", 5));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("e", "objective", 4));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("f", "objective", 6));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("g", "objective", 9));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("h", "objective", 8));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("i", "objective", 7));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("p", "objective", 10));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("o", "objective", 11));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("n", "objective", 12));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("m", "objective", 13));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("k", "objective", 14));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("l", "objective", 15));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("j", "objective", 16));
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("q", "objective", 17));
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team1",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_RED,
+                    new String[]{ "f", "o" }
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(1, "0", 17, "q"),
+                    new ScoreInfo(2, "0", 16, "j"),
+                    new ScoreInfo(3, "0", 15, "l"),
+                    new ScoreInfo(4, "0", 14, "k"),
+                    new ScoreInfo(5, "0", 13, "m"),
+                    new ScoreInfo(6, "0", 12, "n"),
+                    new ScoreInfo(7, "0", 11, "§4prefix§r§4o§r§4suffix"),
+                    new ScoreInfo(8, "0", 10, "p"),
+                    new ScoreInfo(9, "0", 9, "g"),
+                    new ScoreInfo(10, "0", 8, "h"),
+                    new ScoreInfo(11, "0", 7, "i"),
+                    new ScoreInfo(12, "0", 6, "§4prefix§r§4f§r§4suffix"),
+                    new ScoreInfo(13, "0", 5, "d"),
+                    new ScoreInfo(14, "0", 4, "e"),
+                    new ScoreInfo(15, "0", 3, "c")
+                ));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            // remove a score
+            context.translate(
+                resetScoreTranslator,
+                new ClientboundResetScorePacket("m", "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(new ScoreInfo(5, "0", 13, "m")));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b")));
+                return packet;
+            }, context);
+
+            // add a score
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("aa", "objective", 13)
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(new ScoreInfo(16, "0", 2, "b")));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(17, "0", 13, "aa")));
+                return packet;
+            }, context);
+
+            // add some teams for the upcoming score adds
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team2",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_AQUA,
+                    new String[]{ "oa" }
+                )
+            );
+            context.translate(
+                setPlayerTeamTranslator,
+                new ClientboundSetPlayerTeamPacket(
+                    "team3",
+                    Component.text("displayName"),
+                    Component.text("prefix"),
+                    Component.text("suffix"),
+                    false,
+                    false,
+                    NameTagVisibility.ALWAYS,
+                    CollisionRule.NEVER,
+                    TeamColor.DARK_PURPLE,
+                    new String[]{ "ao" }
+                )
+            );
+            assertNoNextPacket(context);
+
+            // add a score that on Java should be after 'o', but would be before on Bedrock without manual order
+            // due to the team color
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("oa", "objective", 11)
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(15, "0", 3, "c"),
+                    new ScoreInfo(7, "0", 11, "§0§r§4prefix§r§4o§r§4suffix")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(7, "0", 11, "§0§r§4prefix§r§4o§r§4suffix"),
+                    new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix")
+                ));
+                return packet;
+            }, context);
+
+            // add a score that on Java should be before 'o', but would be after on Bedrock without manual order
+            // due to the team color
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("ao", "objective", 11)
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(14, "0", 4, "e"),
+                    new ScoreInfo(7, "0", 11, "§1§r§4prefix§r§4o§r§4suffix"),
+                    new ScoreInfo(18, "0", 11, "§2§r§3prefix§r§3oa§r§3suffix")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(19, "0", 11, "§0§r§5prefix§r§5ao§r§5suffix"),
+                    new ScoreInfo(7, "0", 11, "§1§r§4prefix§r§4o§r§4suffix"),
+                    new ScoreInfo(18, "0", 11, "§2§r§3prefix§r§3oa§r§3suffix")
+                ));
+                return packet;
+            }, context);
+
+            // remove original 'o' score
+            context.translate(
+                resetScoreTranslator,
+                new ClientboundResetScorePacket("o", "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(7, "0", 11, "§1§r§4prefix§r§4o§r§4suffix"),
+                    new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix"),
+                    new ScoreInfo(20, "0", 4, "e")
+                ));
+                return packet;
+            }, context);
+
+            // remove the other score with the same value as 'o'
+            context.translate(
+                resetScoreTranslator,
+                new ClientboundResetScorePacket("oa", "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(
+                    new ScoreInfo(18, "0", 11, "§1§r§3prefix§r§3oa§r§3suffix"),
+                    new ScoreInfo(19, "0", 11, "§5prefix§r§5ao§r§5suffix")
+                ));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(
+                    new ScoreInfo(19, "0", 11, "§5prefix§r§5ao§r§5suffix"),
+                    new ScoreInfo(21, "0", 3, "c")
+                ));
+                return packet;
+            }, context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java
new file mode 100644
index 000000000..0a02a58d9
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/sidebar/VanillaSidebarScoreboardTests.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.sidebar;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContextScoreboard.mockContextScoreboard;
+
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import org.cloudburstmc.protocol.bedrock.data.ScoreInfo;
+import org.cloudburstmc.protocol.bedrock.packet.SetDisplayObjectivePacket;
+import org.cloudburstmc.protocol.bedrock.packet.SetScorePacket;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetDisplayObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetObjectiveTranslator;
+import org.geysermc.geyser.translator.protocol.java.scoreboard.JavaSetScoreTranslator;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ObjectiveAction;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreType;
+import org.geysermc.mcprotocollib.protocol.data.game.scoreboard.ScoreboardPosition;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetObjectivePacket;
+import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetScorePacket;
+import org.junit.jupiter.api.Test;
+
+public class VanillaSidebarScoreboardTests {
+    @Test
+    void displayAndAddScore() {
+        mockContextScoreboard(context -> {
+           var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+           var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+           var setScoreTranslator = new JavaSetScoreTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1));
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner")));
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void displayAndChangeScoreValue() {
+        mockContextScoreboard(context -> {
+           var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+           var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+           var setScoreTranslator = new JavaSetScoreTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1));
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner")));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 2));
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "owner")));
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void displayAndChangeScoreDisplayName() {
+        // this ensures that MCPE-143063 is properly handled
+        mockContextScoreboard(context -> {
+           var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+           var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+           var setScoreTranslator = new JavaSetScoreTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1));
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner")));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("owner", "objective", 1).withDisplay(Component.text("hi"))
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "hi")));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "hi")));
+                return packet;
+            }, context);
+        });
+    }
+
+    @Test
+    void displayAndChangeScoreDisplayNameAndValue() {
+        // this ensures that MCPE-143063 is properly handled
+        mockContextScoreboard(context -> {
+           var setObjectiveTranslator = new JavaSetObjectiveTranslator();
+           var setDisplayObjectiveTranslator = new JavaSetDisplayObjectiveTranslator();
+           var setScoreTranslator = new JavaSetScoreTranslator();
+
+            context.translate(
+                setObjectiveTranslator,
+                new ClientboundSetObjectivePacket(
+                    "objective",
+                    ObjectiveAction.ADD,
+                    Component.text("objective"),
+                    ScoreType.INTEGER,
+                    null
+                )
+            );
+            context.translate(setScoreTranslator, new ClientboundSetScorePacket("owner", "objective", 1));
+            assertNoNextPacket(context);
+
+            context.translate(
+                setDisplayObjectiveTranslator,
+                new ClientboundSetDisplayObjectivePacket(ScoreboardPosition.SIDEBAR, "objective")
+            );
+            assertNextPacket(() -> {
+                var packet = new SetDisplayObjectivePacket();
+                packet.setObjectiveId("0");
+                packet.setDisplayName("objective");
+                packet.setCriteria("dummy");
+                packet.setDisplaySlot("sidebar");
+                packet.setSortOrder(1);
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 1, "owner")));
+                return packet;
+            }, context);
+            assertNoNextPacket(context);
+
+            context.translate(
+                setScoreTranslator,
+                new ClientboundSetScorePacket("owner", "objective", 2).withDisplay(Component.text("hi"))
+            );
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.REMOVE);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "hi")));
+                return packet;
+            }, context);
+            assertNextPacket(() -> {
+                var packet = new SetScorePacket();
+                packet.setAction(SetScorePacket.Action.SET);
+                packet.setInfos(List.of(new ScoreInfo(1, "0", 2, "hi")));
+                return packet;
+            }, context);
+        });
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java
new file mode 100644
index 000000000..b15994533
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/AssertUtils.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.util;
+
+import java.util.Collections;
+import java.util.function.Supplier;
+import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket;
+import org.junit.jupiter.api.Assertions;
+
+public class AssertUtils {
+    public static <T> void assertContextEquals(Supplier<? extends T> expected, T actual) {
+        if (actual == null) {
+            Assertions.fail("Expected another packet! " + expected.get());
+        }
+        Assertions.assertEquals(expected.get(), actual);
+    }
+
+    public static void assertNextPacket(Supplier<BedrockPacket> expected, GeyserMockContext context) {
+        assertContextEquals(expected, context.nextPacket());
+    }
+
+    public static void assertNextPacketType(GeyserMockContext context, Class<? extends BedrockPacket> type) {
+        var actual = context.nextPacket();
+        if (actual == null) {
+            Assertions.fail("Expected another packet! " + type);
+        }
+        Assertions.assertEquals(type, actual.getClass());
+    }
+
+    public static void assertNoNextPacket(GeyserMockContext context) {
+        Assertions.assertEquals(
+            Collections.emptyList(),
+            context.packets(),
+            "Expected no remaining packets, got " + context.packetCount()
+        );
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java
new file mode 100644
index 000000000..f147e766d
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.util;
+
+import org.geysermc.geyser.GeyserLogger;
+
+public class EmptyGeyserLogger implements GeyserLogger {
+    @Override
+    public void severe(String message) {
+
+    }
+
+    @Override
+    public void severe(String message, Throwable error) {
+
+    }
+
+    @Override
+    public void error(String message) {
+
+    }
+
+    @Override
+    public void error(String message, Throwable error) {
+
+    }
+
+    @Override
+    public void warning(String message) {
+
+    }
+
+    @Override
+    public void info(String message) {
+
+    }
+
+    @Override
+    public void debug(String message) {
+
+    }
+
+    @Override
+    public void setDebug(boolean debug) {
+
+    }
+
+    @Override
+    public boolean isDebug() {
+        return false;
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java
new file mode 100644
index 000000000..72515d714
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.util;
+
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.configuration.GeyserConfiguration;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+public class GeyserMockContext {
+    private final List<Object> mocksAndSpies = new ArrayList<>();
+    private final List<Object> storedObjects = new ArrayList<>();
+    private final List<BedrockPacket> packets = Collections.synchronizedList(new ArrayList<>());
+    private MockedStatic<GeyserImpl> geyserImplMock;
+
+    public static void mockContext(Consumer<GeyserMockContext> geyserContext) {
+        var context = new GeyserMockContext();
+
+        var geyserImpl = context.mock(GeyserImpl.class);
+        var config = context.mock(GeyserConfiguration.class);
+
+        when(config.getScoreboardPacketThreshold()).thenReturn(1_000);
+
+        when(geyserImpl.getConfig()).thenReturn(config);
+
+        var logger = context.storeObject(new EmptyGeyserLogger());
+        when(geyserImpl.getLogger()).thenReturn(logger);
+
+        try (var mocked = mockStatic(GeyserImpl.class)) {
+            mocked.when(GeyserImpl::getInstance).thenReturn(geyserImpl);
+            context.geyserImplMock = mocked;
+            geyserContext.accept(context);
+        }
+    }
+
+    public static void mockContext(Runnable runnable) {
+        mockContext(context -> runnable.run());
+    }
+
+    public <T> T mock(Class<T> type) {
+        return addMockOrSpy(Mockito.mock(type));
+    }
+
+    public <T> T spy(T object) {
+        return addMockOrSpy(Mockito.spy(object));
+    }
+
+    private <T> T addMockOrSpy(T mockOrSpy) {
+        mocksAndSpies.add(mockOrSpy);
+        return mockOrSpy;
+    }
+
+    public <T> T storeObject(T object) {
+        storedObjects.add(object);
+        return object;
+    }
+
+    /**
+     * Retries the mock or spy that is an instance of the specified type.
+     * This is only really intended for classes where you only need a single instance of.
+     */
+    public <T> T mockOrSpy(Class<T> type) {
+        for (Object mock : mocksAndSpies) {
+            if (type.isInstance(mock)) {
+                return type.cast(mock);
+            }
+        }
+        return null;
+    }
+
+    public <T> T storedObject(Class<T> type) {
+        for (Object storedObject : storedObjects) {
+            if (type.isInstance(storedObject)) {
+                return type.cast(storedObject);
+            }
+        }
+        return null;
+    }
+
+    public GeyserSession session() {
+        return mockOrSpy(GeyserSession.class);
+    }
+
+    void addPacket(BedrockPacket packet) {
+        packets.add(packet);
+    }
+
+    public int packetCount() {
+        return packets.size();
+    }
+
+    public BedrockPacket nextPacket() {
+        if (packets.isEmpty()) {
+            return null;
+        }
+        return packets.remove(0);
+    }
+
+    public List<BedrockPacket> packets() {
+        return Collections.unmodifiableList(packets);
+    }
+
+    public <T> void translate(PacketTranslator<T> translator, T packet) {
+        translator.translate(session(), packet);
+    }
+
+    public MockedStatic<GeyserImpl> geyserImplMock() {
+        return geyserImplMock;
+    }
+}
diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java
new file mode 100644
index 000000000..bc76a1b70
--- /dev/null
+++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContextScoreboard.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.scoreboard.network.util;
+
+import static org.geysermc.geyser.scoreboard.network.util.AssertUtils.assertNoNextPacket;
+import static org.geysermc.geyser.scoreboard.network.util.GeyserMockContext.mockContext;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import java.util.UUID;
+import java.util.function.Consumer;
+import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.entity.type.player.SessionPlayerEntity;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.session.cache.EntityCache;
+import org.geysermc.geyser.session.cache.WorldCache;
+import org.mockito.stubbing.Answer;
+
+public class GeyserMockContextScoreboard {
+    public static void mockContextScoreboard(Consumer<GeyserMockContext> geyserContext) {
+        mockContext(context -> {
+            createSessionSpy(context);
+            geyserContext.accept(context);
+
+            assertNoNextPacket(context);
+        });
+    }
+
+    private static void createSessionSpy(GeyserMockContext context) {
+        // GeyserSession has so many dependencies, it's easier to just mock it
+        var session = context.mock(GeyserSession.class);
+
+        when(session.getGeyser()).thenReturn(context.mockOrSpy(GeyserImpl.class));
+
+        when(session.locale()).thenReturn("en_US");
+
+        doAnswer((Answer<Void>) invocation -> {
+            context.addPacket(invocation.getArgument(0, BedrockPacket.class));
+            return null;
+        }).when(session).sendUpstreamPacket(any());
+
+        // SessionPlayerEntity loads stuff in like blocks, which is not what we want
+        var playerEntity = context.mock(SessionPlayerEntity.class);
+        when(playerEntity.getGeyserId()).thenReturn(1L);
+        when(playerEntity.getUsername()).thenReturn("Tim203");
+        when(session.getPlayerEntity()).thenReturn(playerEntity);
+
+        var entityCache = context.spy(new EntityCache(session));
+        when(session.getEntityCache()).thenReturn(entityCache);
+
+        var worldCache = context.spy(new WorldCache(session));
+        when(session.getWorldCache()).thenReturn(worldCache);
+
+        // disable global scoreboard updater
+        when(worldCache.increaseAndGetScoreboardPacketsPerSecond()).thenReturn(0);
+    }
+
+    public static PlayerEntity mockAndAddPlayerEntity(GeyserMockContext context, String username, long geyserId) {
+        var playerEntity = spy(new PlayerEntity(context.session(), geyserId, UUID.randomUUID(), username));
+        // fake the player being spawned
+        when(playerEntity.isValid()).thenReturn(true);
+
+        var entityCache = context.mockOrSpy(EntityCache.class);
+        entityCache.addPlayerEntity(playerEntity);
+        // called when the player spawns
+        entityCache.cacheEntity(playerEntity);
+        return playerEntity;
+    }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0f9087c8f..3a3831044 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -15,7 +15,7 @@ protocol-common = "3.0.0.Beta5-20240916.181041-6"
 protocol-codec = "3.0.0.Beta5-20240916.181041-6"
 raknet = "1.0.0.CR3-20240416.144209-1"
 minecraftauth = "4.1.1"
-mcprotocollib = "1.21-20240725.013034-16"
+mcprotocollib = "1.21-20241010.155958-24"
 adventure = "4.14.0"
 adventure-platform = "4.3.0"
 junit = "5.9.2"
@@ -39,6 +39,7 @@ neoforge-minecraft = "21.1.1"
 mixin = "0.8.5"
 mixinextras = "0.3.5"
 minecraft = "1.21.1"
+mockito = "5.+"
 
 # plugin versions
 indra = "3.1.3"
@@ -133,6 +134,8 @@ protocol-connection = { group = "org.cloudburstmc.protocol", name = "bedrock-con
 
 math = { group = "org.cloudburstmc.math", name = "immutable", version = "2.0" }
 
+mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
+
 # plugins
 lombok = { group = "io.freefair.gradle", name = "lombok-plugin", version.ref = "lombok" }
 indra = { group = "net.kyori", name  = "indra-common", version.ref = "indra" }