diff --git a/connector/pom.xml b/connector/pom.xml
index d837f057f..db267a717 100644
--- a/connector/pom.xml
+++ b/connector/pom.xml
@@ -143,17 +143,23 @@
             <scope>compile</scope>
         </dependency>
         <dependency>
-            <groupId>net.kyori</groupId>
+            <groupId>com.github.kyoripowered.adventure</groupId>
             <artifactId>adventure-text-serializer-gson</artifactId>
-            <version>4.1.1</version>
+            <version>4d8a67d798</version>
             <scope>compile</scope>
         </dependency>
         <dependency>
-            <groupId>net.kyori</groupId>
+            <groupId>com.github.kyoripowered.adventure</groupId>
             <artifactId>adventure-text-serializer-legacy</artifactId>
-            <version>4.1.1</version>
+            <version>0599048</version>
             <scope>compile</scope>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.13.1</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
@@ -283,6 +289,15 @@
                     </dependency>
                 </dependencies>
             </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>2.22.0</version>
+                <configuration>
+                    <!-- Force the right file encoding during unit testing -->
+                    <argLine>-Dfile.encoding=${project.build.sourceEncoding}</argLine>
+                </configuration>
+            </plugin>
         </plugins>
     </build>
 </project>
diff --git a/connector/src/main/java/org/geysermc/connector/entity/CommandBlockMinecartEntity.java b/connector/src/main/java/org/geysermc/connector/entity/CommandBlockMinecartEntity.java
index 8cabba645..7d34cc795 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/CommandBlockMinecartEntity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/CommandBlockMinecartEntity.java
@@ -26,13 +26,12 @@
 package org.geysermc.connector.entity;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.EntityMetadata;
-import com.github.steveice10.mc.protocol.data.message.Message;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import org.geysermc.connector.entity.type.EntityType;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 public class CommandBlockMinecartEntity extends DefaultBlockMinecartEntity {
 
@@ -51,7 +50,7 @@ public class CommandBlockMinecartEntity extends DefaultBlockMinecartEntity {
             metadata.put(EntityData.COMMAND_BLOCK_COMMAND, entityMetadata.getValue());
         }
         if (entityMetadata.getId() == 14) {
-            metadata.put(EntityData.COMMAND_BLOCK_LAST_OUTPUT, MessageUtils.getBedrockMessage((Message) entityMetadata.getValue()));
+            metadata.put(EntityData.COMMAND_BLOCK_LAST_OUTPUT, MessageTranslator.convertMessage(entityMetadata.getValue().toString()));
         }
         super.updateBedrockMetadata(entityMetadata, session);
     }
diff --git a/connector/src/main/java/org/geysermc/connector/entity/Entity.java b/connector/src/main/java/org/geysermc/connector/entity/Entity.java
index 20cd2f76b..7b1fa1cf0 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/Entity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/Entity.java
@@ -54,7 +54,7 @@ import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.item.ItemRegistry;
 import org.geysermc.connector.utils.AttributeUtils;
 import org.geysermc.connector.utils.ChunkUtils;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -318,7 +318,7 @@ public class Entity {
                     Message message = (Message) entityMetadata.getValue();
                     if (message != null)
                         // Always translate even if it's a TextMessage since there could be translatable parameters
-                        metadata.put(EntityData.NAMETAG, MessageUtils.getTranslatedBedrockMessage(message, session.getLocale(), true));
+                        metadata.put(EntityData.NAMETAG, MessageTranslator.convertMessage(message.toString(), session.getLocale()));
                 }
                 break;
             case 3: // is custom name visible
diff --git a/connector/src/main/java/org/geysermc/connector/entity/PlayerEntity.java b/connector/src/main/java/org/geysermc/connector/entity/PlayerEntity.java
index 8eeae4736..be65525cb 100644
--- a/connector/src/main/java/org/geysermc/connector/entity/PlayerEntity.java
+++ b/connector/src/main/java/org/geysermc/connector/entity/PlayerEntity.java
@@ -51,7 +51,7 @@ import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.session.cache.EntityEffectCache;
 import org.geysermc.connector.scoreboard.Team;
 import org.geysermc.connector.utils.AttributeUtils;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -243,13 +243,13 @@ public class PlayerEntity extends LivingEntity {
             String username = this.username;
             TextMessage name = (TextMessage) entityMetadata.getValue();
             if (name != null) {
-                username = MessageUtils.getBedrockMessage(name);
+                username = MessageTranslator.convertMessage(name.toString());
             }
             Team team = session.getWorldCache().getScoreboard().getTeamFor(username);
             if (team != null) {
                 String displayName = "";
                 if (team.isVisibleFor(session.getPlayerEntity().getUsername())) {
-                    displayName = MessageUtils.toChatColor(team.getColor()) + username;
+                    displayName = MessageTranslator.toChatColor(team.getColor()) + username;
                     displayName = team.getCurrentData().getDisplayName(displayName);
                 }
                 metadata.put(EntityData.NAMETAG, displayName);
diff --git a/connector/src/main/java/org/geysermc/connector/network/ConnectorServerEventHandler.java b/connector/src/main/java/org/geysermc/connector/network/ConnectorServerEventHandler.java
index 9fb4ad9e1..150d298c7 100644
--- a/connector/src/main/java/org/geysermc/connector/network/ConnectorServerEventHandler.java
+++ b/connector/src/main/java/org/geysermc/connector/network/ConnectorServerEventHandler.java
@@ -25,7 +25,6 @@
 
 package org.geysermc.connector.network;
 
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
 import com.nukkitx.protocol.bedrock.BedrockPong;
 import com.nukkitx.protocol.bedrock.BedrockServerEventHandler;
 import com.nukkitx.protocol.bedrock.BedrockServerSession;
@@ -36,7 +35,7 @@ import org.geysermc.connector.GeyserConnector;
 import org.geysermc.connector.configuration.GeyserConfiguration;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.ping.IGeyserPingPassthrough;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 import org.geysermc.connector.utils.LanguageUtils;
 
 import java.net.InetSocketAddress;
@@ -76,7 +75,7 @@ public class ConnectorServerEventHandler implements BedrockServerEventHandler {
         pong.setIpv4Port(config.getBedrock().getPort());
 
         if (config.isPassthroughMotd() && pingInfo != null && pingInfo.getDescription() != null) {
-            String[] motd = MessageUtils.getBedrockMessage(MessageSerializer.fromString(pingInfo.getDescription())).split("\n");
+            String[] motd = MessageTranslator.convertMessageLenient(pingInfo.getDescription()).split("\n");
             String mainMotd = motd[0]; // First line of the motd.
             String subMotd = (motd.length != 1) ? motd[1] : ""; // Second line of the motd if present, otherwise blank.
 
diff --git a/connector/src/main/java/org/geysermc/connector/network/QueryPacketHandler.java b/connector/src/main/java/org/geysermc/connector/network/QueryPacketHandler.java
index 7faf36bdd..510bba2d2 100644
--- a/connector/src/main/java/org/geysermc/connector/network/QueryPacketHandler.java
+++ b/connector/src/main/java/org/geysermc/connector/network/QueryPacketHandler.java
@@ -25,12 +25,11 @@
 
 package org.geysermc.connector.network;
 
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
 import io.netty.buffer.ByteBuf;
 import io.netty.buffer.ByteBufAllocator;
 import org.geysermc.connector.common.ping.GeyserPingInfo;
 import org.geysermc.connector.GeyserConnector;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -148,7 +147,7 @@ public class QueryPacketHandler {
         }
 
         if (connector.getConfig().isPassthroughMotd() && pingInfo != null) {
-            String[] javaMotd = MessageUtils.getBedrockMessage(MessageSerializer.fromString(pingInfo.getDescription())).split("\n");
+            String[] javaMotd = MessageTranslator.convertMessageLenient(pingInfo.getDescription()).split("\n");
             motd = javaMotd[0].trim(); // First line of the motd.
         } else {
             motd = connector.getConfig().getBedrock().getMotd1();
diff --git a/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java b/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
index a6085e218..00b48a565 100644
--- a/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
+++ b/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
@@ -34,7 +34,6 @@ import com.github.steveice10.mc.protocol.data.SubProtocol;
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.statistic.Statistic;
 import com.github.steveice10.mc.protocol.data.game.window.VillagerTrade;
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
 import com.github.steveice10.mc.protocol.packet.handshake.client.HandshakePacket;
 import com.github.steveice10.mc.protocol.packet.ingame.client.world.ClientTeleportConfirmPacket;
 import com.github.steveice10.mc.protocol.packet.ingame.server.ServerRespawnPacket;
@@ -66,6 +65,7 @@ import org.geysermc.connector.common.AuthType;
 import org.geysermc.connector.entity.Entity;
 import org.geysermc.connector.entity.PlayerEntity;
 import org.geysermc.connector.inventory.PlayerInventory;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 import org.geysermc.connector.network.remote.RemoteServer;
 import org.geysermc.connector.network.session.auth.AuthData;
 import org.geysermc.connector.network.session.auth.BedrockClientData;
@@ -496,7 +496,7 @@ public class GeyserSession implements CommandSender {
                             event.getCause().printStackTrace();
                         }
 
-                        upstream.disconnect(MessageUtils.getBedrockMessage(MessageSerializer.fromString(event.getReason())));
+                        upstream.disconnect(MessageTranslator.convertMessageLenient(event.getReason()));
                     }
 
                     @Override
diff --git a/connector/src/main/java/org/geysermc/connector/network/session/cache/BossBar.java b/connector/src/main/java/org/geysermc/connector/network/session/cache/BossBar.java
index fdc609ab9..7eadb7942 100644
--- a/connector/src/main/java/org/geysermc/connector/network/session/cache/BossBar.java
+++ b/connector/src/main/java/org/geysermc/connector/network/session/cache/BossBar.java
@@ -33,7 +33,7 @@ import com.nukkitx.protocol.bedrock.packet.BossEventPacket;
 import com.nukkitx.protocol.bedrock.packet.RemoveEntityPacket;
 import lombok.AllArgsConstructor;
 import org.geysermc.connector.network.session.GeyserSession;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @AllArgsConstructor
 public class BossBar {
@@ -58,7 +58,7 @@ public class BossBar {
         BossEventPacket bossEventPacket = new BossEventPacket();
         bossEventPacket.setBossUniqueEntityId(entityId);
         bossEventPacket.setAction(BossEventPacket.Action.CREATE);
-        bossEventPacket.setTitle(MessageUtils.getTranslatedBedrockMessage(title, session.getLocale()));
+        bossEventPacket.setTitle(MessageTranslator.convertMessage(title.toString(), session.getLocale()));
         bossEventPacket.setHealthPercentage(health);
         bossEventPacket.setColor(color); //ignored by client
         bossEventPacket.setOverlay(overlay);
@@ -72,7 +72,7 @@ public class BossBar {
         BossEventPacket bossEventPacket = new BossEventPacket();
         bossEventPacket.setBossUniqueEntityId(entityId);
         bossEventPacket.setAction(BossEventPacket.Action.UPDATE_NAME);
-        bossEventPacket.setTitle(MessageUtils.getTranslatedBedrockMessage(title, session.getLocale()));
+        bossEventPacket.setTitle(MessageTranslator.convertMessage(title.toString(), session.getLocale()));
 
         session.sendUpstreamPacket(bossEventPacket);
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockCommandRequestTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockCommandRequestTranslator.java
index 1f31367c3..f572538e7 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockCommandRequestTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockCommandRequestTranslator.java
@@ -34,7 +34,7 @@ import org.geysermc.connector.network.translators.Translator;
 
 import com.github.steveice10.mc.protocol.packet.ingame.client.ClientChatPacket;
 import com.nukkitx.protocol.bedrock.packet.CommandRequestPacket;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @Translator(packet = CommandRequestPacket.class)
 public class BedrockCommandRequestTranslator extends PacketTranslator<CommandRequestPacket> {
@@ -48,7 +48,7 @@ public class BedrockCommandRequestTranslator extends PacketTranslator<CommandReq
         } else {
             String message = packet.getCommand().trim();
 
-            if (MessageUtils.isTooLong(message, session)) {
+            if (MessageTranslator.isTooLong(message, session)) {
                 return;
             }
 
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockTextTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockTextTranslator.java
index ad650f98b..e4a765694 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockTextTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockTextTranslator.java
@@ -31,7 +31,7 @@ import org.geysermc.connector.network.translators.Translator;
 
 import com.github.steveice10.mc.protocol.packet.ingame.client.ClientChatPacket;
 import com.nukkitx.protocol.bedrock.packet.TextPacket;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @Translator(packet = TextPacket.class)
 public class BedrockTextTranslator extends PacketTranslator<TextPacket> {
@@ -40,7 +40,7 @@ public class BedrockTextTranslator extends PacketTranslator<TextPacket> {
     public void translate(TextPacket packet, GeyserSession session) {
         String message = packet.getMessage().replaceAll("^\\.", "/").trim();
 
-        if (MessageUtils.isTooLong(message, session)) {
+        if (MessageTranslator.isTooLong(message, session)) {
             return;
         }
 
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java
new file mode 100644
index 000000000..be01362fa
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright (c) 2019-2020 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.connector.network.translators.chat;
+
+import com.github.steveice10.mc.protocol.data.game.scoreboard.TeamColor;
+import com.github.steveice10.mc.protocol.data.message.style.ChatColor;
+import com.github.steveice10.mc.protocol.data.message.style.ChatFormat;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.renderer.TranslatableComponentRenderer;
+import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
+import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
+import net.kyori.adventure.translation.TranslationRegistry;
+import org.geysermc.connector.network.session.GeyserSession;
+import org.geysermc.connector.utils.LanguageUtils;
+
+import java.util.*;
+
+public class MessageTranslator {
+
+    // These are used for handling the translations of the messages
+    private static final TranslationRegistry REGISTRY = new MinecraftTranslationRegistry();
+    private static final TranslatableComponentRenderer<Locale> RENDERER = TranslatableComponentRenderer.usingTranslationSource(REGISTRY);
+
+    // Store team colors for player names
+    private static final Map<TeamColor, String> TEAM_COLORS = new HashMap<>();
+
+    static {
+        TEAM_COLORS.put(TeamColor.BLACK, getColor(ChatColor.BLACK));
+        TEAM_COLORS.put(TeamColor.DARK_BLUE, getColor(ChatColor.DARK_BLUE));
+        TEAM_COLORS.put(TeamColor.DARK_GREEN, getColor(ChatColor.DARK_GREEN));
+        TEAM_COLORS.put(TeamColor.DARK_AQUA, getColor(ChatColor.DARK_AQUA));
+        TEAM_COLORS.put(TeamColor.DARK_RED, getColor(ChatColor.DARK_RED));
+        TEAM_COLORS.put(TeamColor.DARK_PURPLE, getColor(ChatColor.DARK_PURPLE));
+        TEAM_COLORS.put(TeamColor.GOLD, getColor(ChatColor.GOLD));
+        TEAM_COLORS.put(TeamColor.GRAY, getColor(ChatColor.GRAY));
+        TEAM_COLORS.put(TeamColor.DARK_GRAY, getColor(ChatColor.DARK_GRAY));
+        TEAM_COLORS.put(TeamColor.BLUE, getColor(ChatColor.BLUE));
+        TEAM_COLORS.put(TeamColor.GREEN, getColor(ChatColor.GREEN));
+        TEAM_COLORS.put(TeamColor.AQUA, getColor(ChatColor.AQUA));
+        TEAM_COLORS.put(TeamColor.RED, getColor(ChatColor.RED));
+        TEAM_COLORS.put(TeamColor.LIGHT_PURPLE, getColor(ChatColor.LIGHT_PURPLE));
+        TEAM_COLORS.put(TeamColor.YELLOW, getColor(ChatColor.YELLOW));
+        TEAM_COLORS.put(TeamColor.WHITE, getColor(ChatColor.WHITE));
+        TEAM_COLORS.put(TeamColor.OBFUSCATED, getFormat(ChatFormat.OBFUSCATED));
+        TEAM_COLORS.put(TeamColor.BOLD, getFormat(ChatFormat.BOLD));
+        TEAM_COLORS.put(TeamColor.STRIKETHROUGH, getFormat(ChatFormat.STRIKETHROUGH));
+        TEAM_COLORS.put(TeamColor.ITALIC, getFormat(ChatFormat.ITALIC));
+    }
+
+    /**
+     * Convert a Java message to the legacy format ready for bedrock
+     *
+     * @param message Java message
+     * @param locale Locale to use for translation strings
+     * @return Parsed and formatted message for bedrock
+     */
+    public static String convertMessage(String message, String locale) {
+        Component component = GsonComponentSerializer.gson().deserialize(message);
+
+        // Get a Locale from the given locale string
+        Locale localeCode = Locale.forLanguageTag(locale.replace('_', '-'));
+        component = RENDERER.render(component, localeCode);
+
+        return LegacyComponentSerializer.legacySection().serialize(component);
+    }
+
+    public static String convertMessage(String message) {
+        return convertMessage(message, LanguageUtils.getDefaultLocale());
+    }
+
+    /**
+     * Verifies the message is valid JSON in case it's plaintext. Works around GsonComponentSeraializer not using lenient mode.
+     * See https://wiki.vg/Chat for messages sent in lenient mode, and for a description on leniency.
+     *
+     * @param message Potentially lenient JSON message
+     * @param locale Locale to use for translation strings
+     * @return Bedrock formatted message
+     */
+    public static String convertMessageLenient(String message, String locale) {
+        if (isMessage(message)) {
+            return convertMessage(message, locale);
+        } else {
+            String convertedMessage = convertMessage(convertToJavaMessage(message), locale);
+
+            // We have to do this since Adventure strips the starting reset character
+            if (message.startsWith(getColor(ChatColor.RESET))) {
+                convertedMessage = getColor(ChatColor.RESET) + convertedMessage;
+            }
+
+            return convertedMessage;
+        }
+    }
+
+    public static String convertMessageLenient(String message) {
+        return convertMessageLenient(message, LanguageUtils.getDefaultLocale());
+    }
+
+    /**
+     * Convert a Bedrock message string back to a format Java can understand
+     *
+     * @param message Message to convert
+     * @return The formatted JSON string
+     */
+    public static String convertToJavaMessage(String message) {
+        Component component = LegacyComponentSerializer.legacySection().deserialize(message);
+        return GsonComponentSerializer.gson().serialize(component);
+    }
+
+    /**
+     * Checks if the given text string is a JSON message
+     *
+     * @param text String to test
+     * @return True if its a valid message JSON string, false if not
+     */
+    public static boolean isMessage(String text) {
+        if (text.trim().isEmpty()) {
+            return false;
+        }
+
+        try {
+            GsonComponentSerializer.gson().deserialize(text);
+        } catch (Exception ex) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Convert a {@link ChatColor} into a string for inserting into messages
+     *
+     * @param color {@link ChatColor} to convert
+     * @return The converted color string
+     */
+    private static String getColor(String color) {
+        String base = "\u00a7";
+        switch (color) {
+            case ChatColor.BLACK:
+                base += "0";
+                break;
+            case ChatColor.DARK_BLUE:
+                base += "1";
+                break;
+            case ChatColor.DARK_GREEN:
+                base += "2";
+                break;
+            case ChatColor.DARK_AQUA:
+                base += "3";
+                break;
+            case ChatColor.DARK_RED:
+                base += "4";
+                break;
+            case ChatColor.DARK_PURPLE:
+                base += "5";
+                break;
+            case ChatColor.GOLD:
+                base += "6";
+                break;
+            case ChatColor.GRAY:
+                base += "7";
+                break;
+            case ChatColor.DARK_GRAY:
+                base += "8";
+                break;
+            case ChatColor.BLUE:
+                base += "9";
+                break;
+            case ChatColor.GREEN:
+                base += "a";
+                break;
+            case ChatColor.AQUA:
+                base += "b";
+                break;
+            case ChatColor.RED:
+                base += "c";
+                break;
+            case ChatColor.LIGHT_PURPLE:
+                base += "d";
+                break;
+            case ChatColor.YELLOW:
+                base += "e";
+                break;
+            case ChatColor.WHITE:
+                base += "f";
+                break;
+            case ChatColor.RESET:
+                base += "r";
+                break;
+            default:
+                return "";
+        }
+
+        return base;
+    }
+
+    /**
+     * Convert a {@link ChatFormat} into a string for inserting into messages
+     *
+     * @param format {@link ChatFormat} to convert
+     * @return The converted chat formatting string
+     */
+    private static String getFormat(ChatFormat format) {
+        StringBuilder str = new StringBuilder();
+        String base = "\u00a7";
+        switch (format) {
+            case OBFUSCATED:
+                base += "k";
+                break;
+            case BOLD:
+                base += "l";
+                break;
+            case STRIKETHROUGH:
+                base += "m";
+                break;
+            case UNDERLINED:
+                base += "n";
+                break;
+            case ITALIC:
+                base += "o";
+                break;
+            default:
+                return "";
+        }
+
+        str.append(base);
+
+        return str.toString();
+    }
+
+    /**
+     * Convert a team color to a chat color
+     *
+     * @param teamColor
+     * @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
+     *
+     * @param message Message to check
+     * @param session {@link GeyserSession} for the user
+     * @return True if the message is too long, false if not
+     */
+    public static boolean isTooLong(String message, GeyserSession session) {
+        if (message.length() > 256) {
+            session.sendMessage(LanguageUtils.getPlayerLocaleString("geyser.chat.too_long", session.getLocale(), message.length()));
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/chat/MinecraftTranslationRegistry.java b/connector/src/main/java/org/geysermc/connector/network/translators/chat/MinecraftTranslationRegistry.java
new file mode 100644
index 000000000..a23167ac2
--- /dev/null
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/chat/MinecraftTranslationRegistry.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2019-2020 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.connector.network.translators.chat;
+
+import net.kyori.adventure.key.Key;
+import net.kyori.adventure.translation.TranslationRegistry;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.connector.utils.LocaleUtils;
+
+import java.text.MessageFormat;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * This class is used for mapping a translation key with the already loaded Java locale data
+ * Used in MessageTranslator.java as part of the KyoriPowered/Adventure library
+ */
+public class MinecraftTranslationRegistry implements TranslationRegistry {
+    @Override
+    public @NonNull Key name() {
+        return Key.key("", "");
+    }
+
+    @Override
+    public @Nullable MessageFormat translate(@NonNull String key, @NonNull Locale locale) {
+        // Get the locale string
+        String localeString = LocaleUtils.getLocaleString(key, locale.toString());
+
+        // Replace the `%s` with numbered inserts `{0}`
+        Pattern p = Pattern.compile("%s");
+        Matcher m = p.matcher(localeString);
+        StringBuffer sb = new StringBuffer();
+        int i = 0;
+        while (m.find()) {
+            m.appendReplacement(sb, "{" + (i++) + "}");
+        }
+        m.appendTail(sb);
+
+        return new MessageFormat(sb.toString(), locale);
+    }
+
+    @Override
+    public void defaultLocale(@NonNull Locale locale) {
+
+    }
+
+    @Override
+    public void register(@NonNull String key, @NonNull Locale locale, @NonNull MessageFormat format) {
+
+    }
+
+    @Override
+    public void unregister(@NonNull String key) {
+
+    }
+}
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java
index 55db9a254..00c9138a9 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/ItemTranslator.java
@@ -26,7 +26,6 @@
 package org.geysermc.connector.network.translators.item;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
 import com.github.steveice10.opennbt.tag.builtin.*;
 import com.nukkitx.nbt.NbtList;
 import com.nukkitx.nbt.NbtMap;
@@ -44,7 +43,7 @@ import org.geysermc.connector.network.translators.ItemRemapper;
 import org.geysermc.connector.network.translators.world.block.BlockTranslator;
 import org.geysermc.connector.utils.FileUtils;
 import org.geysermc.connector.utils.LanguageUtils;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 import org.reflections.Reflections;
 
 import java.util.*;
@@ -385,26 +384,17 @@ public abstract class ItemTranslator {
     public static void translateDisplayProperties(GeyserSession session, CompoundTag tag) {
         if (tag != null) {
             CompoundTag display = tag.get("display");
-            if (display != null && !display.isEmpty() && display.contains("Name")) {
+            if (display != null && display.contains("Name")) {
                 String name = ((StringTag) display.get("Name")).getValue();
 
-                // If its not a message convert it
-                if (!MessageUtils.isMessage(name)) {
-                    Component component = LegacyComponentSerializer.legacySection().deserialize(name);
-                    name = GsonComponentSerializer.gson().serialize(component);
-                }
+                // Get the translated name and prefix it with a reset char
+                name = MessageTranslator.convertMessageLenient(name, session.getLocale());
 
-                // Check if its a message to translate
-                if (MessageUtils.isMessage(name)) {
-                    // Get the translated name
-                    name = MessageUtils.getTranslatedBedrockMessage(MessageSerializer.fromString(name), session.getLocale());
+                // Add the new name tag
+                display.put(new StringTag("Name", name));
 
-                    // Add the new name tag
-                    display.put(new StringTag("Name", name));
-
-                    // Add to the new root tag
-                    tag.put(display);
-                }
+                // Add to the new root tag
+                tag.put(display);
             }
         }
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BasicItemTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BasicItemTranslator.java
index 1d21bbfb7..3fd9df8a0 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BasicItemTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BasicItemTranslator.java
@@ -37,7 +37,6 @@ import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.ItemRemapper;
 import org.geysermc.connector.network.translators.item.ItemEntry;
 import org.geysermc.connector.network.translators.item.NbtItemStackTranslator;
-import org.geysermc.connector.utils.MessageUtils;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -108,7 +107,7 @@ public class BasicItemTranslator extends NbtItemStackTranslator {
     private String toBedrockMessage(StringTag tag) {
         String message = tag.getValue();
         if (message == null) return null;
-        TextComponent component = (TextComponent) MessageUtils.phraseJavaMessage(message);
+        TextComponent component = (TextComponent) GsonComponentSerializer.gson().deserialize(message);
         String legacy = LegacyComponentSerializer.legacySection().serialize(component);
         if (hasFormatting(LegacyComponentSerializer.legacySection().deserialize(legacy))) {
             return "§r" + legacy;
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BookPagesTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BookPagesTranslator.java
index 41ee4fbca..294dd81ed 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BookPagesTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/translators/nbt/BookPagesTranslator.java
@@ -33,7 +33,7 @@ import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.ItemRemapper;
 import org.geysermc.connector.network.translators.item.NbtItemStackTranslator;
 import org.geysermc.connector.network.translators.item.ItemEntry;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -56,7 +56,7 @@ public class BookPagesTranslator extends NbtItemStackTranslator {
 
             CompoundTag pageTag = new CompoundTag("");
             pageTag.put(new StringTag("photoname", ""));
-            pageTag.put(new StringTag("text", MessageUtils.getBedrockMessageLenient(textTag.getValue())));
+            pageTag.put(new StringTag("text", MessageTranslator.convertMessageLenient(textTag.getValue())));
             pages.add(pageTag);
         }
 
@@ -78,7 +78,7 @@ public class BookPagesTranslator extends NbtItemStackTranslator {
             CompoundTag pageTag = (CompoundTag) tag;
 
             StringTag textTag = pageTag.get("text");
-            pages.add(new StringTag(MessageUtils.getJavaMessage(textTag.getValue())));
+            pages.add(new StringTag(MessageTranslator.convertToJavaMessage(textTag.getValue())));
         }
 
         itemTag.remove("pages");
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaChatTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaChatTranslator.java
index 186aaf660..f5128ed6f 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaChatTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaChatTranslator.java
@@ -25,15 +25,12 @@
 
 package org.geysermc.connector.network.translators.java;
 
-import com.github.steveice10.mc.protocol.data.message.TranslationMessage;
 import com.github.steveice10.mc.protocol.packet.ingame.server.ServerChatPacket;
 import com.nukkitx.protocol.bedrock.packet.TextPacket;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.utils.MessageUtils;
-
-import java.util.List;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @Translator(packet = ServerChatPacket.class)
 public class JavaChatTranslator extends PacketTranslator<ServerChatPacket> {
@@ -59,21 +56,8 @@ public class JavaChatTranslator extends PacketTranslator<ServerChatPacket> {
                 break;
         }
 
-        String locale = session.getLocale();
-
-        if (packet.getMessage() instanceof TranslationMessage) {
-            textPacket.setType(TextPacket.Type.TRANSLATION);
-            textPacket.setNeedsTranslation(true);
-
-            List<String> paramsTranslated = MessageUtils.getTranslationParams(((TranslationMessage) packet.getMessage()).getWith(), locale, packet.getMessage());
-            textPacket.setParameters(paramsTranslated);
-
-            textPacket.setMessage(MessageUtils.insertParams(MessageUtils.getTranslatedBedrockMessage(packet.getMessage(), locale, true, packet.getMessage()), paramsTranslated));
-        } else {
-            textPacket.setNeedsTranslation(false);
-
-            textPacket.setMessage(MessageUtils.getTranslatedBedrockMessage(packet.getMessage(), locale, false, packet.getMessage()));
-        }
+        textPacket.setNeedsTranslation(false);
+        textPacket.setMessage(MessageTranslator.convertMessage(packet.getMessage().toString(), session.getLocale()));
 
         session.sendUpstreamPacket(textPacket);
     }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDisconnectPacket.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDisconnectPacket.java
index f36da367b..1945a8e10 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDisconnectPacket.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaDisconnectPacket.java
@@ -29,13 +29,13 @@ import com.github.steveice10.mc.protocol.packet.ingame.server.ServerDisconnectPa
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @Translator(packet = ServerDisconnectPacket.class)
 public class JavaDisconnectPacket extends PacketTranslator<ServerDisconnectPacket> {
 
     @Override
     public void translate(ServerDisconnectPacket packet, GeyserSession session) {
-        session.disconnect(MessageUtils.getTranslatedBedrockMessage(packet.getReason(), session.getLocale(), true));
+        session.disconnect(MessageTranslator.convertMessage(packet.getReason().toString(), session.getLocale()));
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaLoginDisconnectTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaLoginDisconnectTranslator.java
index e7486c992..0a1cc3ddb 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaLoginDisconnectTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaLoginDisconnectTranslator.java
@@ -29,7 +29,7 @@ import com.github.steveice10.mc.protocol.packet.login.server.LoginDisconnectPack
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @Translator(packet = LoginDisconnectPacket.class)
 public class JavaLoginDisconnectTranslator extends PacketTranslator<LoginDisconnectPacket> {
@@ -37,6 +37,6 @@ public class JavaLoginDisconnectTranslator extends PacketTranslator<LoginDisconn
     @Override
     public void translate(LoginDisconnectPacket packet, GeyserSession session) {
         // The client doesn't manually get disconnected so we have to do it ourselves
-        session.disconnect(MessageUtils.getTranslatedBedrockMessage(packet.getReason(), session.getLocale()));
+        session.disconnect(MessageTranslator.convertMessage(packet.getReason().toString(), session.getLocale()));
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaTitleTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaTitleTranslator.java
index 12ef10d0a..7fe4d7b31 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaTitleTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/JavaTitleTranslator.java
@@ -28,7 +28,7 @@ package org.geysermc.connector.network.translators.java;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.connector.network.translators.PacketTranslator;
 import org.geysermc.connector.network.translators.Translator;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import com.github.steveice10.mc.protocol.packet.ingame.server.ServerTitlePacket;
 import com.nukkitx.protocol.bedrock.packet.SetTitlePacket;
@@ -41,14 +41,21 @@ public class JavaTitleTranslator extends PacketTranslator<ServerTitlePacket> {
         SetTitlePacket titlePacket = new SetTitlePacket();
         String locale = session.getLocale();
 
+        String text;
+        if (packet.getTitle() == null) {
+            text = " ";
+        } else {
+            text = MessageTranslator.convertMessage(packet.getTitle().toString(), locale);
+        }
+
         switch (packet.getAction()) {
             case TITLE:
                 titlePacket.setType(SetTitlePacket.Type.TITLE);
-                titlePacket.setText(MessageUtils.getTranslatedBedrockMessage(packet.getTitle(), locale));
+                titlePacket.setText(text);
                 break;
             case SUBTITLE:
                 titlePacket.setType(SetTitlePacket.Type.SUBTITLE);
-                titlePacket.setText(MessageUtils.getTranslatedBedrockMessage(packet.getTitle(), locale));
+                titlePacket.setText(text);
                 break;
             case CLEAR:
             case RESET:
@@ -57,9 +64,10 @@ public class JavaTitleTranslator extends PacketTranslator<ServerTitlePacket> {
                 break;
             case ACTION_BAR:
                 titlePacket.setType(SetTitlePacket.Type.ACTIONBAR);
-                titlePacket.setText(MessageUtils.getTranslatedBedrockMessage(packet.getTitle(), locale));
+                titlePacket.setText(text);
                 break;
             case TIMES:
+                titlePacket.setType(SetTitlePacket.Type.TIMES);
                 titlePacket.setFadeInTime(packet.getFadeIn());
                 titlePacket.setFadeOutTime(packet.getFadeOut());
                 titlePacket.setStayTime(packet.getStay());
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaScoreboardObjectiveTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaScoreboardObjectiveTranslator.java
index 31b9d95b2..1996f696f 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaScoreboardObjectiveTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaScoreboardObjectiveTranslator.java
@@ -32,7 +32,7 @@ import org.geysermc.connector.network.translators.Translator;
 import org.geysermc.connector.scoreboard.Objective;
 import org.geysermc.connector.scoreboard.Scoreboard;
 import org.geysermc.connector.scoreboard.ScoreboardUpdater;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import com.github.steveice10.mc.protocol.data.game.scoreboard.ObjectiveAction;
 import com.github.steveice10.mc.protocol.packet.ingame.server.scoreboard.ServerScoreboardObjectivePacket;
@@ -54,7 +54,7 @@ public class JavaScoreboardObjectiveTranslator extends PacketTranslator<ServerSc
         switch (packet.getAction()) {
             case ADD:
             case UPDATE:
-                objective.setDisplayName(MessageUtils.getBedrockMessage(packet.getDisplayName()))
+                objective.setDisplayName(MessageTranslator.convertMessage(packet.getDisplayName().toString()))
                         .setType(packet.getType().ordinal());
                 break;
             case REMOVE:
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaTeamTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaTeamTranslator.java
index c621fc1f2..d10b0caa9 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaTeamTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/scoreboard/JavaTeamTranslator.java
@@ -37,7 +37,7 @@ import org.geysermc.connector.scoreboard.ScoreboardUpdater;
 import org.geysermc.connector.scoreboard.Team;
 import org.geysermc.connector.scoreboard.UpdateType;
 import org.geysermc.connector.utils.LanguageUtils;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 import java.util.Arrays;
 import java.util.Set;
@@ -59,11 +59,11 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
         switch (packet.getAction()) {
             case CREATE:
                 scoreboard.registerNewTeam(packet.getTeamName(), toPlayerSet(packet.getPlayers()))
-                        .setName(MessageUtils.getBedrockMessage(packet.getDisplayName()))
+                        .setName(MessageTranslator.convertMessage(packet.getDisplayName().toString()))
                         .setColor(packet.getColor())
                         .setNameTagVisibility(packet.getNameTagVisibility())
-                        .setPrefix(MessageUtils.getTranslatedBedrockMessage(packet.getPrefix(), session.getLocale()))
-                        .setSuffix(MessageUtils.getTranslatedBedrockMessage(packet.getSuffix(), session.getLocale()));
+                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix().toString(), session.getLocale()))
+                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix().toString(), session.getLocale()));
                 break;
             case UPDATE:
                 if (team == null) {
@@ -74,11 +74,11 @@ public class JavaTeamTranslator extends PacketTranslator<ServerTeamPacket> {
                     return;
                 }
 
-                team.setName(MessageUtils.getBedrockMessage(packet.getDisplayName()))
+                team.setName(MessageTranslator.convertMessage(packet.getDisplayName().toString()))
                         .setColor(packet.getColor())
                         .setNameTagVisibility(packet.getNameTagVisibility())
-                        .setPrefix(MessageUtils.getTranslatedBedrockMessage(packet.getPrefix(), session.getLocale()))
-                        .setSuffix(MessageUtils.getTranslatedBedrockMessage(packet.getSuffix(), session.getLocale()))
+                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix().toString(), session.getLocale()))
+                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix().toString(), session.getLocale()))
                         .setUpdateType(UpdateType.UPDATE);
                 break;
             case ADD_PLAYER:
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java
index 2c10ded60..1fb088717 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/window/JavaOpenWindowTranslator.java
@@ -25,7 +25,6 @@
 
 package org.geysermc.connector.network.translators.java.window;
 
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
 import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCloseWindowPacket;
 import com.github.steveice10.mc.protocol.packet.ingame.server.window.ServerOpenWindowPacket;
 import org.geysermc.connector.inventory.Inventory;
@@ -35,7 +34,7 @@ import org.geysermc.connector.network.translators.Translator;
 import org.geysermc.connector.network.translators.inventory.InventoryTranslator;
 import org.geysermc.connector.utils.InventoryUtils;
 import org.geysermc.connector.utils.LocaleUtils;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @Translator(packet = ServerOpenWindowPacket.class)
 public class JavaOpenWindowTranslator extends PacketTranslator<ServerOpenWindowPacket> {
@@ -57,8 +56,7 @@ public class JavaOpenWindowTranslator extends PacketTranslator<ServerOpenWindowP
             return;
         }
 
-        String name = MessageUtils.getTranslatedBedrockMessage(MessageSerializer.fromString(packet.getName()),
-                session.getLocale());
+        String name = MessageTranslator.convertMessageLenient(packet.getName(), session.getLocale());
 
         name = LocaleUtils.getLocaleString(name, session.getLocale());
 
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java
index 2484dba71..1eb50ffe7 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/CommandBlockBlockEntityTranslator.java
@@ -28,7 +28,7 @@ package org.geysermc.connector.network.translators.world.block.entity;
 import com.github.steveice10.opennbt.tag.builtin.*;
 import com.nukkitx.nbt.NbtMapBuilder;
 import org.geysermc.connector.network.translators.world.block.BlockStateValues;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 
 @BlockEntity(name = "CommandBlock", regex = "command_block")
 public class CommandBlockBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
@@ -42,7 +42,7 @@ public class CommandBlockBlockEntityTranslator extends BlockEntityTranslator imp
         // Java and Bedrock values
         builder.put("conditionMet", ((ByteTag) tag.get("conditionMet")).getValue());
         builder.put("auto", ((ByteTag) tag.get("auto")).getValue());
-        builder.put("CustomName", MessageUtils.getBedrockMessage(((StringTag) tag.get("CustomName")).getValue()));
+        builder.put("CustomName", MessageTranslator.convertMessage(((StringTag) tag.get("CustomName")).getValue()));
         builder.put("powered", ((ByteTag) tag.get("powered")).getValue());
         builder.put("Command", ((StringTag) tag.get("Command")).getValue());
         builder.put("SuccessCount", ((IntTag) tag.get("SuccessCount")).getValue());
diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java
index a4f800da1..a9641d772 100644
--- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java
+++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SignBlockEntityTranslator.java
@@ -25,10 +25,9 @@
 
 package org.geysermc.connector.network.translators.world.block.entity;
 
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.nbt.NbtMapBuilder;
-import org.geysermc.connector.utils.MessageUtils;
+import org.geysermc.connector.network.translators.chat.MessageTranslator;
 import org.geysermc.connector.utils.SignUtils;
 
 @BlockEntity(name = "Sign", regex = "sign")
@@ -36,12 +35,12 @@ public class SignBlockEntityTranslator extends BlockEntityTranslator {
     /**
      * Maps a color stored in a sign's Color tag to a Bedrock Edition formatting code.
      * <br>
-     * The color names correspond to dye names, because of this we can't use {@link MessageUtils#getColor(String)}.
+     * The color names correspond to dye names, because of this we can't use {@link MessageTranslator#getColor(String)}.
      *
      * @param javaColor The dye color stored in the sign's Color tag.
      * @return A Bedrock Edition formatting code for valid dye colors, otherwise an empty string.
      */
-    private static String getBedrockSignColor(String javaColor) {
+    private String getBedrockSignColor(String javaColor) {
         String base = "\u00a7";
         switch (javaColor) {
             case "white":
@@ -100,7 +99,12 @@ public class SignBlockEntityTranslator extends BlockEntityTranslator {
         for (int i = 0; i < 4; i++) {
             int currentLine = i + 1;
             String signLine = getOrDefault(tag.getValue().get("Text" + currentLine), "");
-            signLine = MessageUtils.getBedrockMessage(MessageSerializer.fromString(signLine));
+            signLine = MessageTranslator.convertMessageLenient(signLine);
+
+            // Trim any trailing formatting codes
+            if (signLine.length() > 2 && signLine.toCharArray()[signLine.length() - 2] == '\u00a7') {
+                signLine = signLine.substring(0, signLine.length() - 2);
+            }
 
             // Check the character width on the sign to ensure there is no overflow that is usually hidden
             // to Java Edition clients but will appear to Bedrock clients
@@ -124,6 +128,6 @@ public class SignBlockEntityTranslator extends BlockEntityTranslator {
             signText.append("\n");
         }
 
-        builder.put("Text", MessageUtils.getBedrockMessage(MessageSerializer.fromString(signText.toString())));
+        builder.put("Text", signText.toString());
     }
 }
diff --git a/connector/src/main/java/org/geysermc/connector/utils/FileUtils.java b/connector/src/main/java/org/geysermc/connector/utils/FileUtils.java
index 63255cfa0..0b2b132ab 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/FileUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/FileUtils.java
@@ -159,7 +159,8 @@ public class FileUtils {
     }
 
     /**
-     * Calculate the SHA256 hash of the resource pack file
+     * Calculate the SHA256 hash of a file
+     *
      * @param file File to calculate the hash for
      * @return A byte[] representation of the hash
      */
@@ -175,6 +176,24 @@ public class FileUtils {
         return sha256;
     }
 
+    /**
+     * Calculate the SHA1 hash of a file
+     *
+     * @param file File to calculate the hash for
+     * @return A byte[] representation of the hash
+     */
+    public static byte[] calculateSHA1(File file) {
+        byte[] sha1;
+
+        try {
+            sha1 = MessageDigest.getInstance("SHA-1").digest(Files.readAllBytes(file.toPath()));
+        } catch (Exception e) {
+            throw new RuntimeException("Could not calculate pack hash", e);
+        }
+
+        return sha1;
+    }
+
     /**
      * Get the stored reflection data for a given path
      *
diff --git a/connector/src/main/java/org/geysermc/connector/utils/LocaleUtils.java b/connector/src/main/java/org/geysermc/connector/utils/LocaleUtils.java
index dfde21b33..4e9e4b003 100644
--- a/connector/src/main/java/org/geysermc/connector/utils/LocaleUtils.java
+++ b/connector/src/main/java/org/geysermc/connector/utils/LocaleUtils.java
@@ -47,7 +47,7 @@ public class LocaleUtils {
 
     private static final Map<String, Asset> ASSET_MAP = new HashMap<>();
 
-    private static String smallestURL = "";
+    private static VersionDownload clientJarInfo;
 
     static {
         // Create the locales folder
@@ -87,9 +87,8 @@ public class LocaleUtils {
 
             // Get the client jar for use when downloading the en_us locale
             GeyserConnector.getInstance().getLogger().debug(GeyserConnector.JSON_MAPPER.writeValueAsString(versionInfo.getDownloads()));
-            VersionDownload download = versionInfo.getDownloads().get("client");
-            GeyserConnector.getInstance().getLogger().debug(GeyserConnector.JSON_MAPPER.writeValueAsString(download));
-            smallestURL = download.getUrl();
+            clientJarInfo = versionInfo.getDownloads().get("client");
+            GeyserConnector.getInstance().getLogger().debug(GeyserConnector.JSON_MAPPER.writeValueAsString(clientJarInfo));
 
             // Get the assets list
             JsonNode assets = GeyserConnector.JSON_MAPPER.readTree(WebUtils.getBody(versionInfo.getAssetIndex().getUrl())).get("objects");
@@ -136,8 +135,28 @@ public class LocaleUtils {
 
         // Check if we have already downloaded the locale file
         if (localeFile.exists()) {
-            GeyserConnector.getInstance().getLogger().debug("Locale already downloaded: " + locale);
-            return;
+            String curHash = "";
+            String targetHash = "";
+
+            if (locale.equals("en_us")) {
+                try {
+                    Path hashFile = localeFile.getParentFile().toPath().resolve("en_us.hash");
+                    if (hashFile.toFile().exists()) {
+                        curHash = String.join("", Files.readAllLines(hashFile));
+                    }
+                } catch (IOException ignored) { }
+                targetHash = clientJarInfo.getSha1();
+            } else {
+                curHash = byteArrayToHexString(FileUtils.calculateSHA1(localeFile));
+                targetHash = ASSET_MAP.get("minecraft/lang/" + locale + ".json").getHash();
+            }
+
+            if (!curHash.equals(targetHash)) {
+                GeyserConnector.getInstance().getLogger().debug("Locale out of date; re-downloading: " + locale);
+            } else {
+                GeyserConnector.getInstance().getLogger().debug("Locale already downloaded and up-to date: " + locale);
+                return;
+            }
         }
 
         // Create the en_us locale
@@ -202,11 +221,11 @@ public class LocaleUtils {
         try {
             // Let the user know we are downloading the JAR
             GeyserConnector.getInstance().getLogger().info(LanguageUtils.getLocaleStringLog("geyser.locale.download.en_us"));
-            GeyserConnector.getInstance().getLogger().debug("Download URL: " + smallestURL);
+            GeyserConnector.getInstance().getLogger().debug("Download URL: " + clientJarInfo.getUrl());
 
             // Download the smallest JAR (client or server)
             Path tmpFilePath = GeyserConnector.getInstance().getBootstrap().getConfigFolder().resolve("tmp_locale.jar");
-            WebUtils.downloadFile(smallestURL, tmpFilePath.toString());
+            WebUtils.downloadFile(clientJarInfo.getUrl(), tmpFilePath.toString());
 
             // Load in the JAR as a zip and extract the file
             ZipFile localeJar = new ZipFile(tmpFilePath.toString());
@@ -227,6 +246,9 @@ public class LocaleUtils {
             fileStream.close();
             localeJar.close();
 
+            // Store the latest jar hash
+            FileUtils.writeFile(localeFile.getParentFile().toPath().resolve("en_us.hash").toString(), clientJarInfo.getSha1().toCharArray());
+
             // Delete the nolonger needed client/server jar
             Files.delete(tmpFilePath);
         } catch (Exception e) {
@@ -255,6 +277,20 @@ public class LocaleUtils {
         return localeStrings.getOrDefault(messageText, messageText);
     }
 
+    /**
+     * Convert a byte array into a hex string
+     *
+     * @param b Byte array to convert
+     * @return The hex representation of the given byte array
+     */
+    private static String byteArrayToHexString(byte[] b) {
+        StringBuilder result = new StringBuilder();
+        for (byte value : b) {
+            result.append(Integer.toString((value & 0xff) + 0x100, 16).substring(1));
+        }
+        return result.toString();
+    }
+
     public static void init() {
         // no-op
     }
diff --git a/connector/src/main/java/org/geysermc/connector/utils/MessageUtils.java b/connector/src/main/java/org/geysermc/connector/utils/MessageUtils.java
deleted file mode 100644
index b5a2bfdcc..000000000
--- a/connector/src/main/java/org/geysermc/connector/utils/MessageUtils.java
+++ /dev/null
@@ -1,494 +0,0 @@
-/*
- * Copyright (c) 2019-2020 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.connector.utils;
-
-import com.github.steveice10.mc.protocol.data.game.scoreboard.TeamColor;
-import com.github.steveice10.mc.protocol.data.message.Message;
-import com.github.steveice10.mc.protocol.data.message.MessageSerializer;
-import com.github.steveice10.mc.protocol.data.message.TextMessage;
-import com.github.steveice10.mc.protocol.data.message.TranslationMessage;
-import com.github.steveice10.mc.protocol.data.message.style.ChatColor;
-import com.github.steveice10.mc.protocol.data.message.style.ChatFormat;
-import com.github.steveice10.mc.protocol.data.message.style.MessageStyle;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import net.kyori.adventure.text.Component;
-import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
-import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
-import org.geysermc.connector.network.session.GeyserSession;
-
-import java.util.*;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class MessageUtils {
-
-    private static final Map<String, Integer> COLORS = new HashMap<>();
-    private static final Map<TeamColor, String> TEAM_COLORS = new HashMap<>();
-
-    static {
-        COLORS.put(ChatColor.BLACK, 0x000000);
-        COLORS.put(ChatColor.DARK_BLUE, 0x0000aa);
-        COLORS.put(ChatColor.DARK_GREEN, 0x00aa00);
-        COLORS.put(ChatColor.DARK_AQUA, 0x00aaaa);
-        COLORS.put(ChatColor.DARK_RED, 0xaa0000);
-        COLORS.put(ChatColor.DARK_PURPLE, 0xaa00aa);
-        COLORS.put(ChatColor.GOLD, 0xffaa00);
-        COLORS.put(ChatColor.GRAY, 0xaaaaaa);
-        COLORS.put(ChatColor.DARK_GRAY, 0x555555);
-        COLORS.put(ChatColor.BLUE, 0x5555ff);
-        COLORS.put(ChatColor.GREEN, 0x55ff55);
-        COLORS.put(ChatColor.AQUA, 0x55ffff);
-        COLORS.put(ChatColor.RED, 0xff5555);
-        COLORS.put(ChatColor.LIGHT_PURPLE, 0xff55ff);
-        COLORS.put(ChatColor.YELLOW, 0xffff55);
-        COLORS.put(ChatColor.WHITE, 0xffffff);
-
-        TEAM_COLORS.put(TeamColor.BLACK, getColor(ChatColor.BLACK));
-        TEAM_COLORS.put(TeamColor.DARK_BLUE, getColor(ChatColor.DARK_BLUE));
-        TEAM_COLORS.put(TeamColor.DARK_GREEN, getColor(ChatColor.DARK_GREEN));
-        TEAM_COLORS.put(TeamColor.DARK_AQUA, getColor(ChatColor.DARK_AQUA));
-        TEAM_COLORS.put(TeamColor.DARK_RED, getColor(ChatColor.DARK_RED));
-        TEAM_COLORS.put(TeamColor.DARK_PURPLE, getColor(ChatColor.DARK_PURPLE));
-        TEAM_COLORS.put(TeamColor.GOLD, getColor(ChatColor.GOLD));
-        TEAM_COLORS.put(TeamColor.GRAY, getColor(ChatColor.GRAY));
-        TEAM_COLORS.put(TeamColor.DARK_GRAY, getColor(ChatColor.DARK_GRAY));
-        TEAM_COLORS.put(TeamColor.BLUE, getColor(ChatColor.BLUE));
-        TEAM_COLORS.put(TeamColor.GREEN, getColor(ChatColor.GREEN));
-        TEAM_COLORS.put(TeamColor.AQUA, getColor(ChatColor.AQUA));
-        TEAM_COLORS.put(TeamColor.RED, getColor(ChatColor.RED));
-        TEAM_COLORS.put(TeamColor.LIGHT_PURPLE, getColor(ChatColor.LIGHT_PURPLE));
-        TEAM_COLORS.put(TeamColor.YELLOW, getColor(ChatColor.YELLOW));
-        TEAM_COLORS.put(TeamColor.WHITE, getColor(ChatColor.WHITE));
-        TEAM_COLORS.put(TeamColor.OBFUSCATED, getFormat(Collections.singletonList(ChatFormat.OBFUSCATED)));
-        TEAM_COLORS.put(TeamColor.BOLD, getFormat(Collections.singletonList(ChatFormat.BOLD)));
-        TEAM_COLORS.put(TeamColor.STRIKETHROUGH, getFormat(Collections.singletonList(ChatFormat.STRIKETHROUGH)));
-        TEAM_COLORS.put(TeamColor.ITALIC, getFormat(Collections.singletonList(ChatFormat.ITALIC)));
-    }
-
-    /**
-     * Recursively parse each message from a list for usage in a {@link TranslationMessage}
-     *
-     * @param messages A {@link List} of {@link Message} to parse
-     * @param locale A locale loaded to get the message for
-     * @param parent A {@link Message} to use as the parent (can be null)
-     * @return the translation parameters
-     */
-    public static List<String> getTranslationParams(List<Message> messages, String locale, Message parent) {
-        List<String> strings = new ArrayList<>();
-        for (Message message : messages) {
-            message = fixMessageStyle(message, parent);
-
-            if (message instanceof TranslationMessage) {
-                TranslationMessage translation = (TranslationMessage) message;
-
-                if (locale == null) {
-                    String builder = "%" + translation.getKey();
-                    strings.add(builder);
-                }
-
-                // Collect all params and add format corrections to the end of them
-                List<String> furtherParams = new ArrayList<>();
-                for (String param : getTranslationParams(translation.getWith(), locale, message)) {
-                    String newParam = param;
-                    if (parent.getStyle().getFormats().size() != 0) {
-                        newParam += getFormat(parent.getStyle().getFormats());
-                    }
-                    if (parent.getStyle().getColor() != ChatColor.NONE) {
-                        newParam += getColor(parent.getStyle().getColor());
-                    }
-
-                    furtherParams.add(newParam);
-                }
-
-                if (locale != null) {
-                    String builder = getFormat(message.getStyle().getFormats()) +
-                            getColor(message.getStyle().getColor());
-                    builder += insertParams(LocaleUtils.getLocaleString(translation.getKey(), locale), furtherParams);
-                    strings.add(builder);
-                } else {
-                    String format = getFormat(message.getStyle().getFormats()) +
-                            getColor(message.getStyle().getColor());
-                    for (String param : furtherParams) {
-                        strings.add(format + param);
-                    }
-                }
-            } else {
-                String builder = getFormat(message.getStyle().getFormats()) +
-                        getColor(message.getStyle().getColor());
-                builder += getTranslatedBedrockMessage(message, locale, false, parent);
-                strings.add(builder);
-            }
-        }
-
-        return strings;
-    }
-
-    public static String getTranslatedBedrockMessage(Message message, String locale) {
-        return getTranslatedBedrockMessage(message, locale, true);
-    }
-
-    public static String getTranslatedBedrockMessage(Message message, String locale, boolean shouldTranslate) {
-        return getTranslatedBedrockMessage(message, locale, shouldTranslate, null);
-    }
-
-    /**
-     * Translate a given {@link TranslationMessage} to the given locale
-     *
-     * @param message The {@link Message} to send
-     * @param locale the locale
-     * @param shouldTranslate if the message should be translated
-     * @param parent the parent message
-     * @return the given translation message translated from the given locale
-     */
-    public static String getTranslatedBedrockMessage(Message message, String locale, boolean shouldTranslate, Message parent) {
-        JsonParser parser = new JsonParser();
-        if (isMessage(message.toString())) {
-            JsonObject object = parser.parse(message.toString()).getAsJsonObject();
-            message = MessageSerializer.fromJson(object);
-        }
-
-        message = fixMessageStyle(message, parent);
-
-        String messageText = (message instanceof TranslationMessage) ? ((TranslationMessage) message).getKey() : ((TextMessage) message).getText();
-        if (locale != null && shouldTranslate) {
-            messageText = LocaleUtils.getLocaleString(messageText, locale);
-        }
-
-        StringBuilder builder = new StringBuilder();
-        builder.append(getFormat(message.getStyle().getFormats()));
-        builder.append(getColor(message.getStyle().getColor()));
-        builder.append(messageText);
-
-        for (Message msg : message.getExtra()) {
-            builder.append(getFormat(msg.getStyle().getFormats()));
-            builder.append(getColor(msg.getStyle().getColor()));
-            if (!(msg.toString() == null)) {
-                boolean isTranslationMessage = (msg instanceof TranslationMessage);
-                String extraText = "";
-
-                if (isTranslationMessage) {
-                    List<String> paramsTranslated =  getTranslationParams(((TranslationMessage) msg).getWith(), locale, message);
-                    extraText = insertParams(getTranslatedBedrockMessage(msg, locale, isTranslationMessage, message), paramsTranslated);
-                } else {
-                    extraText = getTranslatedBedrockMessage(msg, locale, isTranslationMessage, message);
-                }
-
-                builder.append(extraText);
-                builder.append("\u00a7r");
-            }
-        }
-
-        return builder.toString();
-    }
-
-    /**
-     * If the passed {@link Message} color or format are empty then copy from parent
-     *
-     * @param message {@link Message} to update
-     * @param parent Parent {@link Message} for style
-     * @return The updated {@link Message}
-     */
-    private static Message fixMessageStyle(Message message, Message parent) {
-        if (parent == null) {
-            return message;
-        }
-        MessageStyle.Builder styleBuilder = message.getStyle().toBuilder();
-
-        // Copy color from parent
-        if (message.getStyle().getColor() == ChatColor.NONE) {
-            styleBuilder.color(parent.getStyle().getColor());
-        }
-
-        // Copy formatting from parent
-        if (message.getStyle().getFormats().size() == 0) {
-            styleBuilder.formats(parent.getStyle().getFormats());
-        }
-
-        return message.toBuilder().style(styleBuilder.build()).build();
-    }
-
-    public static String getBedrockMessage(Message message) {
-        if (isMessage(((TextMessage) message).getText())) {
-            return getBedrockMessage(((TextMessage) message).getText());
-        } else {
-            return getBedrockMessage(MessageSerializer.toJsonString(message));
-        }
-    }
-
-    /**
-     * Verifies the message is valid JSON in case it's plaintext. Works around GsonComponentSeraializer not using lenient mode.
-     * See https://wiki.vg/Chat for messages sent in lenient mode, and for a description on leniency.
-     *
-     * @param message Potentially lenient JSON message
-     * @return Bedrock formatted message
-     */
-    public static String getBedrockMessageLenient(String message) {
-        if (isMessage(message)) {
-            return getBedrockMessage(message);
-        } else {
-            final JsonObject obj = new JsonObject();
-            obj.addProperty("text", message);
-            return getBedrockMessage(obj.toString());
-        }
-    }
-
-    public static String getBedrockMessage(String message) {
-        Component component = phraseJavaMessage(message);
-        return LegacyComponentSerializer.legacySection().serialize(component);
-    }
-
-    public static Component phraseJavaMessage(String message) {
-        return GsonComponentSerializer.gson().deserialize(message);
-    }
-
-    public static String getJavaMessage(String message) {
-        Component component = LegacyComponentSerializer.legacySection().deserialize(message);
-        return GsonComponentSerializer.gson().serialize(component);
-    }
-
-    /**
-     * Inserts the given parameters into the given message both in sequence and as requested
-     *
-     * @param message Message containing possible parameter replacement strings
-     * @param params  A list of parameter strings
-     * @return Parsed message with all params inserted as needed
-     */
-    public static String insertParams(String message, List<String> params) {
-        String newMessage = message;
-
-        Pattern p = Pattern.compile("%([1-9])\\$s");
-        Matcher m = p.matcher(message);
-        while (m.find()) {
-            try {
-                newMessage = newMessage.replaceFirst("%" + m.group(1) + "\\$s", params.get(Integer.parseInt(m.group(1)) - 1));
-            } catch (Exception e) {
-                // Couldn't find the param to replace
-            }
-        }
-
-        for (String text : params) {
-            newMessage = newMessage.replaceFirst("%s", text.replaceAll("%s", "%r"));
-        }
-
-        newMessage = newMessage.replaceAll("%r", "MISSING!");
-
-        return newMessage;
-    }
-
-    /**
-     * Convert a ChatColor into a string for inserting into messages
-     *
-     * @param color ChatColor to convert
-     * @return The converted color string
-     */
-    private static String getColor(String color) {
-        String base = "\u00a7";
-        switch (color) {
-            case ChatColor.BLACK:
-                base += "0";
-                break;
-            case ChatColor.DARK_BLUE:
-                base += "1";
-                break;
-            case ChatColor.DARK_GREEN:
-                base += "2";
-                break;
-            case ChatColor.DARK_AQUA:
-                base += "3";
-                break;
-            case ChatColor.DARK_RED:
-                base += "4";
-                break;
-            case ChatColor.DARK_PURPLE:
-                base += "5";
-                break;
-            case ChatColor.GOLD:
-                base += "6";
-                break;
-            case ChatColor.GRAY:
-                base += "7";
-                break;
-            case ChatColor.DARK_GRAY:
-                base += "8";
-                break;
-            case ChatColor.BLUE:
-                base += "9";
-                break;
-            case ChatColor.GREEN:
-                base += "a";
-                break;
-            case ChatColor.AQUA:
-                base += "b";
-                break;
-            case ChatColor.RED:
-                base += "c";
-                break;
-            case ChatColor.LIGHT_PURPLE:
-                base += "d";
-                break;
-            case ChatColor.YELLOW:
-                base += "e";
-                break;
-            case ChatColor.WHITE:
-                base += "f";
-                break;
-            case ChatColor.RESET:
-            //case NONE:
-                base += "r";
-                break;
-            case "": // To stop recursion
-                return "";
-            default:
-                return getClosestColor(color);
-        }
-
-        return base;
-    }
-
-    /**
-     * Based on https://github.com/ViaVersion/ViaBackwards/blob/master/core/src/main/java/nl/matsv/viabackwards/protocol/protocol1_15_2to1_16/chat/TranslatableRewriter1_16.java
-     *
-     * @param color A color string
-     * @return The closest color to that string
-     */
-    private static String getClosestColor(String color) {
-        if (!color.startsWith("#")) {
-            return "";
-        }
-
-        int rgb = Integer.parseInt(color.substring(1), 16);
-        int r = (rgb >> 16) & 0xFF;
-        int g = (rgb >> 8) & 0xFF;
-        int b = rgb & 0xFF;
-
-        String closest = null;
-        int smallestDiff = 0;
-
-        for (Map.Entry<String, Integer> testColor : COLORS.entrySet()) {
-            if (testColor.getValue() == rgb) {
-                closest = testColor.getKey();
-                break;
-            }
-
-            int testR = (testColor.getValue() >> 16) & 0xFF;
-            int testG = (testColor.getValue() >> 8) & 0xFF;
-            int testB = testColor.getValue() & 0xFF;
-
-            // Check by the greatest diff of the 3 values
-            int rAverage = (testR + r) / 2;
-            int rDiff = testR - r;
-            int gDiff = testG - g;
-            int bDiff = testB - b;
-            int diff = ((2 + (rAverage >> 8)) * rDiff * rDiff)
-                    + (4 * gDiff * gDiff)
-                    + ((2 + ((255 - rAverage) >> 8)) * bDiff * bDiff);
-            if (closest == null || diff < smallestDiff) {
-                closest = testColor.getKey();
-                smallestDiff = diff;
-            }
-        }
-
-        return getColor(closest);
-    }
-
-    /**
-     * Convert a list of ChatFormats into a string for inserting into messages
-     *
-     * @param formats ChatFormats to convert
-     * @return The converted chat formatting string
-     */
-    private static String getFormat(List<ChatFormat> formats) {
-        StringBuilder str = new StringBuilder();
-        for (ChatFormat cf : formats) {
-            String base = "\u00a7";
-            switch (cf) {
-                case OBFUSCATED:
-                    base += "k";
-                    break;
-                case BOLD:
-                    base += "l";
-                    break;
-                case STRIKETHROUGH:
-                    base += "m";
-                    break;
-                case UNDERLINED:
-                    base += "n";
-                    break;
-                case ITALIC:
-                    base += "o";
-                    break;
-                default:
-                    return "";
-            }
-
-            str.append(base);
-        }
-
-        return str.toString();
-    }
-
-    /**
-     * Checks if the given text string is a json message
-     *
-     * @param text String to test
-     * @return True if its a valid message json string, false if not
-     */
-    public static boolean isMessage(String text) {
-        JsonParser parser = new JsonParser();
-        try {
-            JsonObject object = parser.parse(text).getAsJsonObject();
-            try {
-                MessageSerializer.fromJson(object);
-            } catch (Exception ex) {
-                return false;
-            }
-        } catch (Exception ex) {
-            return false;
-        }
-        return true;
-    }
-
-    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
-     *
-     * @param message Message to check
-     * @param session GeyserSession for the user
-     * @return True if the message is too long, false if not
-     */
-    public static boolean isTooLong(String message, GeyserSession session) {
-        if (message.length() > 256) {
-            session.sendMessage(LanguageUtils.getPlayerLocaleString("geyser.chat.too_long", session.getLocale(), message.length()));
-            return true;
-        }
-
-        return false;
-    }
-}
diff --git a/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java b/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java
new file mode 100644
index 000000000..5d52c79b9
--- /dev/null
+++ b/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2019-2020 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.connector.network.translators.chat;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class MessageTranslatorTest {
+
+    private Map<String, String> messages = new HashMap<>();
+
+    @Before
+    public void setUp() throws Exception {
+        messages.put("{\"text\":\"\",\"extra\":[{\"text\":\"DoctorMad9952 joined the game\",\"color\":\"yellow\"}]}",
+                "§eDoctorMad9952 joined the game");
+
+        messages.put("{\"text\":\"\",\"extra\":[\"Plugins (3): \",{\"text\":\"WorldEdit\",\"color\":\"green\"},{\"text\":\", \",\"color\":\"white\"},{\"text\":\"ViaVersion\",\"color\":\"green\"},{\"text\":\", \",\"color\":\"white\"},{\"text\":\"Geyser-Spigot\",\"color\":\"green\"}]}",
+                "Plugins (3): §aWorldEdit§f, §aViaVersion§f, §aGeyser-Spigot");
+
+        // RGB downgrade test
+        messages.put("{\"extra\":[{\"text\":\"          \"},{\"color\":\"gold\",\"text\":\"The \"},{\"color\":\"#E14248\",\"obfuscated\":true,\"text\":\"||\"},{\"color\":\"#3AA9FF\",\"bold\":true,\"text\":\"CubeCraft\"},{\"color\":\"#E14248\",\"obfuscated\":true,\"text\":\"||\"},{\"color\":\"gold\",\"text\":\" Network \"},{\"color\":\"green\",\"text\":\"[1.8/1.9+]\\n         \"},{\"color\":\"#f5e342\",\"text\":\"✦ \"},{\"color\":\"#b042f5\",\"bold\":true,\"text\":\"N\"},{\"color\":\"#c142f5\",\"bold\":true,\"text\":\"E\"},{\"color\":\"#d342f5\",\"bold\":true,\"text\":\"W\"},{\"color\":\"#e442f5\",\"bold\":true,\"text\":\":\"},{\"color\":\"#f542f5\",\"bold\":true,\"text\":\" \"},{\"color\":\"#bcf542\",\"bold\":true,\"text\":\"A\"},{\"color\":\"#acee3f\",\"bold\":true,\"text\":\"M\"},{\"color\":\"#9ce73c\",\"bold\":true,\"text\":\"O\"},{\"color\":\"#8ce039\",\"bold\":true,\"text\":\"N\"},{\"color\":\"#7cd936\",\"bold\":true,\"text\":\"G\"},{\"color\":\"#6cd233\",\"bold\":true,\"text\":\" \"},{\"color\":\"#5ccb30\",\"bold\":true,\"text\":\"S\"},{\"color\":\"#4cc42d\",\"bold\":true,\"text\":\"L\"},{\"color\":\"#3cbd2a\",\"bold\":true,\"text\":\"I\"},{\"color\":\"#2cb627\",\"bold\":true,\"text\":\"M\"},{\"color\":\"#1caf24\",\"bold\":true,\"text\":\"E\"},{\"color\":\"#0ca821\",\"bold\":true,\"text\":\"S\"},{\"color\":\"#f5e342\",\"text\":\" \"},{\"color\":\"#6d7c87\",\"text\":\"(kinda sus) \"},{\"color\":\"#f5e342\",\"text\":\"✦\"}],\"text\":\"\"}",
+                "          §6The §c§k||§r§3§lCubeCraft§r§c§k||§r§6 Network §a[1.8/1.9+]\n" +
+                        "         §e✦ §d§lN§r§d§lE§r§d§lW§r§d§l:§r§d§l §r§e§lA§r§e§lM§r§a§lO§r§a§lN§r§a§lG§r§a§l §r§a§lS§r§a§lL§r§2§lI§r§2§lM§r§2§lE§r§2§lS§r§e §8(kinda sus) §e✦");
+    }
+
+    @Test
+    public void convertMessage() {
+        for (Map.Entry<String, String> entry : messages.entrySet()) {
+            String bedrockMessage = MessageTranslator.convertMessage(entry.getKey(), "en_US");
+            Assert.assertEquals("Translation of messages is incorrect", bedrockMessage, entry.getValue());
+        }
+    }
+
+    @Test
+    public void convertMessageLenient() {
+        Assert.assertEquals("All newline message is not handled properly", "\n\n\n\n", MessageTranslator.convertMessageLenient("\n\n\n\n"));
+        Assert.assertEquals("Empty message is not handled properly", "", MessageTranslator.convertMessageLenient(""));
+        Assert.assertEquals("Reset before message is not handled properly", "§r§eGame Selector", MessageTranslator.convertMessageLenient("§r§eGame Selector"));
+    }
+}