diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml
index 9621fa1d0..9d925c4dc 100644
--- a/.github/workflows/pullrequest.yml
+++ b/.github/workflows/pullrequest.yml
@@ -9,47 +9,43 @@ jobs:
 
     steps:
       - uses: actions/checkout@v2
-      - uses: actions/cache@v2
-        with:
-          path: ~/.m2/repository
-          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
-          restore-keys: |
-            ${{ runner.os }}-maven-
-      - name: Set up JDK 16
+      - name: Set up JDK 17
         uses: actions/setup-java@v1
         with:
-          java-version: 16
+          distribution: 'temurin'
+          java-version: 17
+          cache: 'gradle'
       - name: submodules-init
         uses: snickerbockers/submodules-init@v4
-      - name: Build with Maven
-        run: mvn -B package -T 2C
+      - name: Build with Gradle
+        run: ./gradlew build
       - name: Archive artifacts (Geyser Standalone)
         uses: actions/upload-artifact@v2
         if: success()
         with:
           name: Geyser Standalone
-          path: bootstrap/standalone/target/Geyser.jar
+          path: bootstrap/standalone/build/libs/Geyser.jar
       - name: Archive artifacts (Geyser Spigot)
         uses: actions/upload-artifact@v2
         if: success()
         with:
           name: Geyser Spigot
-          path: bootstrap/spigot/target/Geyser-Spigot.jar
+          path: bootstrap/spigot/build/libs/Geyser-Spigot.jar
       - name: Archive artifacts (Geyser BungeeCord)
         uses: actions/upload-artifact@v2
         if: success()
         with:
           name: Geyser BungeeCord
-          path: bootstrap/bungeecord/target/Geyser-BungeeCord.jar
+          path: bootstrap/bungeecord/build/libs/Geyser-BungeeCord.jar
       - name: Archive artifacts (Geyser Sponge)
         uses: actions/upload-artifact@v2
         if: success()
         with:
           name: Geyser Sponge
-          path: bootstrap/sponge/target/Geyser-Sponge.jar
+          path: bootstrap/sponge/build/libs/Geyser-Sponge.jar
       - name: Archive artifacts (Geyser Velocity)
         uses: actions/upload-artifact@v2
         if: success()
         with:
           name: Geyser Velocity
-          path: bootstrap/velocity/target/Geyser-Velocity.jar
+          path: bootstrap/velocity/build/libs/Geyser-Velocity.jar
diff --git a/.gitignore b/.gitignore
index f1baa3abb..2b7e2972c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -235,8 +235,12 @@ nbdist/
 
 # End of https://www.gitignore.io/api/git,java,maven,eclipse,netbeans,jetbrains+all,visualstudiocode
 
+### Gradle ###
+.gradle
+
 ### Geyser ###
 run/
+extensions/
 config.yml
 logs/
 key.pem
@@ -245,4 +249,5 @@ locales/
 /packs/
 /dump.json
 /saved-refresh-tokens.json
-/languages/
\ No newline at end of file
+/custom_mappings/
+/languages/
diff --git a/Jenkinsfile b/Jenkinsfile
index 1a98f47ad..28f9e7a37 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,8 +1,8 @@
 pipeline {
     agent any
     tools {
-        maven 'Maven 3'
-        jdk 'Java 16'
+        gradle 'Gradle 7'
+        jdk 'Java 17'
     }
     options {
         buildDiscarder(logRotator(artifactNumToKeepStr: '20'))
@@ -11,11 +11,16 @@ pipeline {
         stage ('Build') {
             steps {
                 sh 'git submodule update --init --recursive'
-                sh 'mvn clean package'
+                rtGradleRun(
+                    usesPlugin: true,
+                    tool: 'Gradle 7',
+                    buildFile: 'build.gradle.kts',
+                    tasks: 'clean build',
+                )
             }
             post {
                 success {
-                    archiveArtifacts artifacts: 'bootstrap/**/target/*.jar', excludes: 'bootstrap/**/target/original-*.jar', fingerprint: true
+                    archiveArtifacts artifacts: 'bootstrap/**/build/libs/*.jar', excludes: 'bootstrap/**/build/libs/*-sources.jar,bootstrap/**/build/libs/*-unshaded.jar', fingerprint: true
                 }
             }
         }
@@ -24,27 +29,30 @@ pipeline {
             when {
                 anyOf {
                     branch "master"
+                    branch "feature/extensions"
                 }
             }
 
             steps {
-                rtMavenDeployer(
-                        id: "maven-deployer",
+                rtGradleDeployer(
+                        id: "GRADLE_DEPLOYER",
                         serverId: "opencollab-artifactory",
                         releaseRepo: "maven-releases",
                         snapshotRepo: "maven-snapshots"
                 )
-                rtMavenResolver(
-                        id: "maven-resolver",
-                        serverId: "opencollab-artifactory",
-                        releaseRepo: "maven-deploy-release",
-                        snapshotRepo: "maven-deploy-snapshot"
+                rtGradleResolver(
+                        id: "GRADLE_RESOLVER",
+                        serverId: "opencollab-artifactory"
                 )
-                rtMavenRun(
-                        pom: 'pom.xml',
-                        goals: 'javadoc:jar source:jar install -pl :core -am -DskipTests',
-                        deployerId: "maven-deployer",
-                        resolverId: "maven-resolver"
+                rtGradleRun(
+                        usesPlugin: true,
+                        tool: 'Gradle 7',
+                        rootDir: "",
+                        useWrapper: true,
+                        buildFile: 'build.gradle.kts',
+                        tasks: 'build artifactoryPublish',
+                        deployerId: "GRADLE_DEPLOYER",
+                        resolverId: "GRADLE_RESOLVER"
                 )
                 rtPublishBuildInfo(
                         serverId: "opencollab-artifactory"
diff --git a/README.md b/README.md
index 464e67d76..d02d50d2b 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,6 @@ Take a look [here](https://wiki.geysermc.org/geyser/setup/) for how to set up Ge
 
 ## What's Left to be Added/Fixed
 - Near-perfect movement (to the point where anticheat on large servers is unlikely to ban you)
-- Resource pack conversion/CustomModelData
 - Some Entity Flags
 - Structure block UI
 
@@ -43,9 +42,8 @@ There are a few things Geyser is unable to support due to various differences be
 
 ## Compiling
 1. Clone the repo to your computer
-2. [Install Maven](https://maven.apache.org/install.html)
-3. Navigate to the Geyser root directory and run `git submodule update --init --recursive`. This command downloads all the needed submodules for Geyser and is a crucial step in this process.
-4. Run `mvn clean install` and locate to the `target` folder.
+2. Navigate to the Geyser root directory and run `git submodule update --init --recursive`. This command downloads all the needed submodules for Geyser and is a crucial step in this process.
+3. Run `gradlew build` and locate to `bootstrap/build` folder.
 
 ## Contributing
 Any contributions are appreciated. Please feel free to reach out to us on [Discord](http://discord.geysermc.org/) if
diff --git a/ap/build.gradle.kts b/ap/build.gradle.kts
new file mode 100644
index 000000000..e69de29bb
diff --git a/ap/pom.xml b/ap/pom.xml
deleted file mode 100644
index feb77e922..000000000
--- a/ap/pom.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>geyser-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-
-    <artifactId>ap</artifactId>
-    <version>2.0.7-SNAPSHOT</version>
-</project>
\ No newline at end of file
diff --git a/ap/src/main/java/org/geysermc/processor/BlockEntityProcessor.java b/ap/src/main/java/org/geysermc/geyser/processor/BlockEntityProcessor.java
similarity index 97%
rename from ap/src/main/java/org/geysermc/processor/BlockEntityProcessor.java
rename to ap/src/main/java/org/geysermc/geyser/processor/BlockEntityProcessor.java
index 7ab760cec..f9ba68302 100644
--- a/ap/src/main/java/org/geysermc/processor/BlockEntityProcessor.java
+++ b/ap/src/main/java/org/geysermc/geyser/processor/BlockEntityProcessor.java
@@ -23,7 +23,7 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.processor;
+package org.geysermc.geyser.processor;
 
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.annotation.processing.SupportedSourceVersion;
diff --git a/ap/src/main/java/org/geysermc/processor/ClassProcessor.java b/ap/src/main/java/org/geysermc/geyser/processor/ClassProcessor.java
similarity index 96%
rename from ap/src/main/java/org/geysermc/processor/ClassProcessor.java
rename to ap/src/main/java/org/geysermc/geyser/processor/ClassProcessor.java
index a6259a853..e1da50f25 100644
--- a/ap/src/main/java/org/geysermc/processor/ClassProcessor.java
+++ b/ap/src/main/java/org/geysermc/geyser/processor/ClassProcessor.java
@@ -23,7 +23,7 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.processor;
+package org.geysermc.geyser.processor;
 
 import javax.annotation.processing.AbstractProcessor;
 import javax.annotation.processing.ProcessingEnvironment;
@@ -39,6 +39,7 @@ import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.Collection;
@@ -163,10 +164,15 @@ public class ClassProcessor extends AbstractProcessor {
             this.processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Reading existing " + this.annotationClassName + " list from " + this.outputPath);
             return Files.newBufferedReader(this.outputPath);
         }
+
         FileObject obj = this.processingEnv.getFiler().getResource(StandardLocation.CLASS_OUTPUT, "", this.annotationClassName);
         if (obj != null) {
             this.processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Reading existing " + this.annotationClassName + " list from " + obj.toUri());
-            return new BufferedReader(obj.openReader(false));
+            try {
+                return new BufferedReader(obj.openReader(false));
+            } catch (NoSuchFileException ignored) {
+                return null;
+            }
         }
         return null;
     }
diff --git a/ap/src/main/java/org/geysermc/processor/CollisionRemapperProcessor.java b/ap/src/main/java/org/geysermc/geyser/processor/CollisionRemapperProcessor.java
similarity index 97%
rename from ap/src/main/java/org/geysermc/processor/CollisionRemapperProcessor.java
rename to ap/src/main/java/org/geysermc/geyser/processor/CollisionRemapperProcessor.java
index 971abd984..84e2e2ffd 100644
--- a/ap/src/main/java/org/geysermc/processor/CollisionRemapperProcessor.java
+++ b/ap/src/main/java/org/geysermc/geyser/processor/CollisionRemapperProcessor.java
@@ -23,7 +23,7 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.processor;
+package org.geysermc.geyser.processor;
 
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.annotation.processing.SupportedSourceVersion;
diff --git a/ap/src/main/java/org/geysermc/processor/ItemRemapperProcessor.java b/ap/src/main/java/org/geysermc/geyser/processor/ItemRemapperProcessor.java
similarity index 97%
rename from ap/src/main/java/org/geysermc/processor/ItemRemapperProcessor.java
rename to ap/src/main/java/org/geysermc/geyser/processor/ItemRemapperProcessor.java
index 39d5f9fdf..2dd00506d 100644
--- a/ap/src/main/java/org/geysermc/processor/ItemRemapperProcessor.java
+++ b/ap/src/main/java/org/geysermc/geyser/processor/ItemRemapperProcessor.java
@@ -23,7 +23,7 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.processor;
+package org.geysermc.geyser.processor;
 
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.annotation.processing.SupportedSourceVersion;
diff --git a/ap/src/main/java/org/geysermc/processor/PacketTranslatorProcessor.java b/ap/src/main/java/org/geysermc/geyser/processor/PacketTranslatorProcessor.java
similarity index 97%
rename from ap/src/main/java/org/geysermc/processor/PacketTranslatorProcessor.java
rename to ap/src/main/java/org/geysermc/geyser/processor/PacketTranslatorProcessor.java
index 97687e981..9b99d679b 100644
--- a/ap/src/main/java/org/geysermc/processor/PacketTranslatorProcessor.java
+++ b/ap/src/main/java/org/geysermc/geyser/processor/PacketTranslatorProcessor.java
@@ -23,7 +23,7 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.processor;
+package org.geysermc.geyser.processor;
 
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.annotation.processing.SupportedSourceVersion;
diff --git a/ap/src/main/java/org/geysermc/processor/SoundHandlerProcessor.java b/ap/src/main/java/org/geysermc/geyser/processor/SoundHandlerProcessor.java
similarity index 97%
rename from ap/src/main/java/org/geysermc/processor/SoundHandlerProcessor.java
rename to ap/src/main/java/org/geysermc/geyser/processor/SoundHandlerProcessor.java
index 3e6a7c412..c35c0ee4e 100644
--- a/ap/src/main/java/org/geysermc/processor/SoundHandlerProcessor.java
+++ b/ap/src/main/java/org/geysermc/geyser/processor/SoundHandlerProcessor.java
@@ -23,7 +23,7 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.processor;
+package org.geysermc.geyser.processor;
 
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.annotation.processing.SupportedSourceVersion;
diff --git a/ap/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/ap/src/main/resources/META-INF/services/javax.annotation.processing.Processor
new file mode 100644
index 000000000..1f6475b61
--- /dev/null
+++ b/ap/src/main/resources/META-INF/services/javax.annotation.processing.Processor
@@ -0,0 +1,5 @@
+org.geysermc.geyser.processor.BlockEntityProcessor
+org.geysermc.geyser.processor.CollisionRemapperProcessor
+org.geysermc.geyser.processor.ItemRemapperProcessor
+org.geysermc.geyser.processor.PacketTranslatorProcessor
+org.geysermc.geyser.processor.SoundHandlerProcessor
\ No newline at end of file
diff --git a/api/base/build.gradle.kts b/api/base/build.gradle.kts
new file mode 100644
index 000000000..a6fa608cc
--- /dev/null
+++ b/api/base/build.gradle.kts
@@ -0,0 +1,7 @@
+dependencies {
+    api("org.geysermc.cumulus", "cumulus", Versions.cumulusVersion)
+    api("org.geysermc.event", "events", Versions.eventsVersion) {
+        exclude(group = "com.google.guava", module = "guava")
+        exclude(group = "org.lanternpowered", module = "lmbda")
+    }
+}
\ No newline at end of file
diff --git a/api/base/pom.xml b/api/base/pom.xml
deleted file mode 100644
index 4e172650e..000000000
--- a/api/base/pom.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>api-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <modelVersion>4.0.0</modelVersion>
-
-    <artifactId>base-api</artifactId>
-
-    <properties>
-        <maven.compiler.source>16</maven.compiler.source>
-        <maven.compiler.target>16</maven.compiler.target>
-    </properties>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.checkerframework</groupId>
-            <artifactId>checker-qual</artifactId>
-            <version>3.19.0</version>
-            <scope>provided</scope>
-        </dependency>
-    </dependencies>
-</project>
\ No newline at end of file
diff --git a/api/base/src/main/java/org/geysermc/api/Geyser.java b/api/base/src/main/java/org/geysermc/api/Geyser.java
index 9f315faf4..7543d1661 100644
--- a/api/base/src/main/java/org/geysermc/api/Geyser.java
+++ b/api/base/src/main/java/org/geysermc/api/Geyser.java
@@ -39,6 +39,7 @@ public class Geyser {
      *
      * @return the base api
      */
+    @NonNull
     public static GeyserApiBase api() {
         if (api == null) {
             throw new RuntimeException("Api has not been registered yet!");
@@ -69,7 +70,7 @@ public class Geyser {
 
     /**
      * Registers the given api type. The api cannot be
-     * registered if {@link #registered()} is true as
+     * registered if {@link #isRegistered()} is true as
      * an api has already been specified.
      *
      * @param api the api
@@ -88,7 +89,7 @@ public class Geyser {
      *
      * @return if the api has been registered
      */
-    public static boolean registered() {
+    public static boolean isRegistered() {
         return api != null;
     }
 }
diff --git a/api/base/src/main/java/org/geysermc/api/GeyserApiBase.java b/api/base/src/main/java/org/geysermc/api/GeyserApiBase.java
index 3549a912a..a845e37fd 100644
--- a/api/base/src/main/java/org/geysermc/api/GeyserApiBase.java
+++ b/api/base/src/main/java/org/geysermc/api/GeyserApiBase.java
@@ -25,9 +25,13 @@
 
 package org.geysermc.api;
 
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
-import org.geysermc.api.session.Connection;
+import org.checkerframework.common.value.qual.IntRange;
+import org.geysermc.api.connection.Connection;
+import org.geysermc.cumulus.form.Form;
+import org.geysermc.cumulus.form.util.FormBuilder;
 
 import java.util.List;
 import java.util.UUID;
@@ -37,52 +41,88 @@ import java.util.UUID;
  */
 public interface GeyserApiBase {
     /**
-     * Gets the session from the given UUID, if applicable. The player must be logged in to the Java server
+     * Gets the connection from the given UUID, if applicable. The player must be logged in to the Java server
      * for this to return a non-null value.
      *
-     * @param uuid the UUID of the session
-     * @return the session from the given UUID, if applicable
+     * @param uuid the UUID of the connection
+     * @return the connection from the given UUID, if applicable
      */
     @Nullable
     Connection connectionByUuid(@NonNull UUID uuid);
 
     /**
-     * Gets the session from the given
-     * XUID, if applicable.
+     * Gets the connection from the given XUID, if applicable. This method only works for online connections.
      *
      * @param xuid the XUID of the session
-     * @return the session from the given UUID, if applicable
+     * @return the connection from the given UUID, if applicable
      */
     @Nullable
     Connection connectionByXuid(@NonNull String xuid);
 
     /**
-     * Gets the session from the given
-     * name, if applicable.
+     * Method to determine if the given <b>online</b> player is a Bedrock player.
      *
-     * @param name the uuid of the session
-     * @return the session from the given name, if applicable
+     * @param uuid the uuid of the online player
+     * @return true if the given online player is a Bedrock player
      */
-    @Nullable
-    Connection connectionByName(@NonNull String name);
+    boolean isBedrockPlayer(@NonNull UUID uuid);
 
     /**
-     * Gets all the online sessions.
+     * Sends a form to the given connection and opens it.
      *
-     * @return all the online sessions
+     * @param uuid the uuid of the connection to open it on
+     * @param form the form to send
+     * @return whether the form was successfully sent
+     */
+    boolean sendForm(@NonNull UUID uuid, @NonNull Form form);
+
+    /**
+     * Sends a form to the given connection and opens it.
+     *
+     * @param uuid        the uuid of the connection to open it on
+     * @param formBuilder the formBuilder to send
+     * @return whether the form was successfully sent
+     */
+    boolean sendForm(@NonNull UUID uuid, @NonNull FormBuilder<?, ?, ?> formBuilder);
+
+    /**
+     * Transfer the given connection to a server. A Bedrock player can successfully transfer to the same server they are
+     * currently playing on.
+     *
+     * @param uuid    the uuid of the connection
+     * @param address the address of the server
+     * @param port    the port of the server
+     * @return true if the transfer was a success
+     */
+    boolean transfer(@NonNull UUID uuid, @NonNull String address, @IntRange(from = 0, to = 65535) int port);
+
+
+    /**
+     * Returns all the online connections.
      */
     @NonNull
     List<? extends Connection> onlineConnections();
 
     /**
-     * @return the major API version. Bumped whenever a significant breaking change or feature addition is added.
+     * Returns the amount of online connections.
+     */
+    int onlineConnectionsCount();
+
+    /**
+     * Returns the prefix used by Floodgate. Will be null when the auth-type isn't Floodgate.
+     */
+    @MonotonicNonNull
+    String usernamePrefix();
+
+    /**
+     * Returns the major API version. Bumped whenever a significant breaking change or feature addition is added.
      */
     default int majorApiVersion() {
-        return 0;
+        return 1;
     }
 
     /**
-     * @return the minor API version. May be bumped for new API additions.
+     * Returns the minor API version. May be bumped for new API additions.
      */
     default int minorApiVersion() {
         return 0;
diff --git a/api/base/src/main/java/org/geysermc/api/connection/Connection.java b/api/base/src/main/java/org/geysermc/api/connection/Connection.java
new file mode 100644
index 000000000..1cd7a9d13
--- /dev/null
+++ b/api/base/src/main/java/org/geysermc/api/connection/Connection.java
@@ -0,0 +1,121 @@
+/*
+ * 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.api.connection;
+
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.common.value.qual.IntRange;
+import org.geysermc.api.util.BedrockPlatform;
+import org.geysermc.api.util.InputMode;
+import org.geysermc.api.util.UiProfile;
+import org.geysermc.cumulus.form.Form;
+import org.geysermc.cumulus.form.util.FormBuilder;
+
+import java.util.UUID;
+
+/**
+ * Represents a player connection.
+ */
+public interface Connection {
+    /**
+     * Returns the bedrock name of the connection.
+     */
+    @NonNull String bedrockUsername();
+
+    /**
+     * Returns the java name of the connection.
+     */
+    @MonotonicNonNull
+    String javaUsername();
+
+    /**
+     * Returns the UUID of the connection.
+     */
+    @MonotonicNonNull
+    UUID javaUuid();
+
+    /**
+     * Returns the XUID of the connection.
+     */
+    @NonNull String xuid();
+
+    /**
+     * Returns the version of the Bedrock client.
+     */
+    @NonNull String version();
+
+    /**
+     * Returns the platform that the connection is playing on.
+     */
+    @NonNull BedrockPlatform platform();
+
+    /**
+     * Returns the language code of the connection.
+     */
+    @NonNull String languageCode();
+
+    /**
+     * Returns the User Interface Profile of the connection.
+     */
+    @NonNull UiProfile uiProfile();
+
+    /**
+     * Returns the Input Mode of the Bedrock client.
+     */
+    @NonNull InputMode inputMode();
+
+    /**
+     * Returns whether the connection is linked.
+     * This will always return false when the auth-type isn't Floodgate.
+     */
+    boolean isLinked();
+
+    /**
+     * Sends a form to the connection and opens it.
+     *
+     * @param form the form to send
+     * @return whether the form was successfully sent
+     */
+    boolean sendForm(@NonNull Form form);
+
+    /**
+     * Sends a form to the connection and opens it.
+     *
+     * @param formBuilder the formBuilder to send
+     * @return whether the form was successfully sent
+     */
+    boolean sendForm(@NonNull FormBuilder<?, ?, ?> formBuilder);
+
+    /**
+     * Transfer the connection to a server. A Bedrock player can successfully transfer to the same server they are
+     * currently playing on.
+     *
+     * @param address the address of the server
+     * @param port    the port of the server
+     * @return true if the transfer was a success
+     */
+    boolean transfer(@NonNull String address, @IntRange(from = 0, to = 65535) int port);
+}
diff --git a/api/base/src/main/java/org/geysermc/api/util/BedrockPlatform.java b/api/base/src/main/java/org/geysermc/api/util/BedrockPlatform.java
new file mode 100644
index 000000000..e486f73bc
--- /dev/null
+++ b/api/base/src/main/java/org/geysermc/api/util/BedrockPlatform.java
@@ -0,0 +1,73 @@
+/*
+ * 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.api.util;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public enum BedrockPlatform {
+    UNKNOWN("Unknown"),
+    GOOGLE("Android"),
+    IOS("iOS"),
+    OSX("macOS"),
+    AMAZON("Amazon"),
+    GEARVR("Gear VR"),
+    HOLOLENS("Hololens"),
+    UWP("Windows 10"),
+    WIN32("Windows x86"),
+    DEDICATED("Dedicated"),
+    TVOS("Apple TV"),
+    PS4("PS4"),
+    NX("Switch"),
+    XBOX("Xbox One"),
+    WINDOWS_PHONE("Windows Phone");
+
+    private static final BedrockPlatform[] VALUES = values();
+
+    private final String displayName;
+
+    BedrockPlatform(String displayName) {
+        this.displayName = displayName;
+    }
+
+    /**
+     * Get the BedrockPlatform from the identifier.
+     *
+     * @param id the BedrockPlatform identifier
+     * @return The BedrockPlatform or {@link #UNKNOWN} if the platform wasn't found
+     */
+    @NonNull
+    public static BedrockPlatform fromId(int id) {
+        return id < VALUES.length ? VALUES[id] : VALUES[0];
+    }
+
+    /**
+     * @return friendly display name of platform.
+     */
+    @Override
+    public String toString() {
+        return displayName;
+    }
+}
diff --git a/api/base/src/main/java/org/geysermc/api/util/InputMode.java b/api/base/src/main/java/org/geysermc/api/util/InputMode.java
new file mode 100644
index 000000000..70346ffa5
--- /dev/null
+++ b/api/base/src/main/java/org/geysermc/api/util/InputMode.java
@@ -0,0 +1,49 @@
+/*
+ * 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.api.util;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public enum InputMode {
+    UNKNOWN,
+    KEYBOARD_MOUSE,
+    TOUCH,
+    CONTROLLER,
+    VR;
+
+    private static final InputMode[] VALUES = values();
+
+    /**
+     * Get the InputMode from the identifier.
+     *
+     * @param id the InputMode identifier
+     * @return The InputMode or {@link #UNKNOWN} if the mode wasn't found
+     */
+    @NonNull
+    public static InputMode fromId(int id) {
+        return VALUES.length > id ? VALUES[id] : VALUES[0];
+    }
+}
\ No newline at end of file
diff --git a/api/base/src/main/java/org/geysermc/api/util/UiProfile.java b/api/base/src/main/java/org/geysermc/api/util/UiProfile.java
new file mode 100644
index 000000000..cddb97260
--- /dev/null
+++ b/api/base/src/main/java/org/geysermc/api/util/UiProfile.java
@@ -0,0 +1,45 @@
+/*
+ * 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.api.util;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public enum UiProfile {
+    CLASSIC, POCKET;
+
+    private static final UiProfile[] VALUES = values();
+
+    /**
+     * Get the UiProfile from the identifier.
+     *
+     * @param id the UiProfile identifier
+     * @return The UiProfile or {@link #CLASSIC} if the profile wasn't found
+     */
+    @NonNull
+    public static UiProfile fromId(int id) {
+        return VALUES.length > id ? VALUES[id] : VALUES[0];
+    }
+}
diff --git a/api/geyser/build.gradle.kts b/api/geyser/build.gradle.kts
new file mode 100644
index 000000000..dcde85337
--- /dev/null
+++ b/api/geyser/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+    id("geyser.api-conventions")
+}
+
+dependencies {
+    api(projects.api)
+}
+
+publishing {
+    publications.named<MavenPublication>("mavenJava") {
+        groupId = rootProject.group as String + ".geyser"
+        artifactId = "api"
+    }
+}
\ No newline at end of file
diff --git a/api/geyser/pom.xml b/api/geyser/pom.xml
deleted file mode 100644
index 9aa8560d1..000000000
--- a/api/geyser/pom.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>api-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <modelVersion>4.0.0</modelVersion>
-
-    <artifactId>geyser-api</artifactId>
-
-    <properties>
-        <maven.compiler.source>16</maven.compiler.source>
-        <maven.compiler.target>16</maven.compiler.target>
-    </properties>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.checkerframework</groupId>
-            <artifactId>checker-qual</artifactId>
-            <version>3.19.0</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>base-api</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-    </dependencies>
-</project>
\ No newline at end of file
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/GeyserApi.java b/api/geyser/src/main/java/org/geysermc/geyser/api/GeyserApi.java
index 074918881..f86206d36 100644
--- a/api/geyser/src/main/java/org/geysermc/geyser/api/GeyserApi.java
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/GeyserApi.java
@@ -27,8 +27,14 @@ package org.geysermc.geyser.api;
 
 import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.api.Geyser;
 import org.geysermc.api.GeyserApiBase;
 import org.geysermc.geyser.api.connection.GeyserConnection;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.extension.ExtensionManager;
+import org.geysermc.geyser.api.network.BedrockListener;
+import org.geysermc.geyser.api.network.RemoteServer;
 
 import java.util.List;
 import java.util.UUID;
@@ -37,24 +43,6 @@ import java.util.UUID;
  * Represents the API used in Geyser.
  */
 public interface GeyserApi extends GeyserApiBase {
-    /**
-     * Shuts down the current Geyser instance.
-     */
-    void shutdown();
-
-    /**
-     * Reloads the current Geyser instance.
-     */
-    void reload();
-
-    /**
-     * Gets if this Geyser instance is running in an IDE. This only needs to be used in cases where files
-     * expected to be in a jarfile are not present.
-     *
-     * @return true if the version number is not 'DEV'.
-     */
-    boolean productionEnvironment();
-
     /**
      * {@inheritDoc}
      */
@@ -67,15 +55,65 @@ public interface GeyserApi extends GeyserApiBase {
     @Override
     @Nullable GeyserConnection connectionByXuid(@NonNull String xuid);
 
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    @Nullable GeyserConnection connectionByName(@NonNull String name);
-
     /**
      * {@inheritDoc}
      */
     @NonNull
     List<? extends GeyserConnection> onlineConnections();
+
+    /**
+     * Gets the {@link ExtensionManager}.
+     *
+     * @return the extension manager
+     */
+    @NonNull
+    ExtensionManager extensionManager();
+
+    /**
+     * Provides an implementation for the specified API type.
+     *
+     * @param apiClass the builder class
+     * @param <R> the implementation type
+     * @param <T> the API type
+     * @return the builder instance
+     */
+    @NonNull
+    <R extends T, T> R provider(@NonNull Class<T> apiClass, @Nullable Object... args);
+
+    /**
+     * Gets the {@link EventBus} for handling
+     * Geyser events.
+     *
+     * @return the event bus
+     */
+    @NonNull
+    EventBus<EventRegistrar> eventBus();
+
+    /**
+     * Gets the default {@link RemoteServer} configured
+     * within the config file that is used by default.
+     *
+     * @return the default remote server used within Geyser
+     */
+    @NonNull
+    RemoteServer defaultRemoteServer();
+
+    /**
+     * Gets the {@link BedrockListener} used for listening
+     * for Minecraft: Bedrock Edition client connections.
+     *
+     * @return the listener used for Bedrock client connectins
+     */
+    @NonNull
+    BedrockListener bedrockListener();
+
+    /**
+     * Gets the current {@link GeyserApiBase} instance.
+     *
+     * @return the current geyser api instance
+     */
+    @NonNull
+    static GeyserApi api() {
+        return Geyser.api(GeyserApi.class);
+    }
 }
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/command/Command.java b/api/geyser/src/main/java/org/geysermc/geyser/api/command/Command.java
new file mode 100644
index 000000000..2f1f2b24d
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/command/Command.java
@@ -0,0 +1,215 @@
+/*
+ * 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.api.command;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.GeyserApi;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+import org.geysermc.geyser.api.extension.Extension;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents a command.
+ */
+public interface Command {
+
+    /**
+     * Gets the command name.
+     *
+     * @return the command name
+     */
+    @NonNull
+    String name();
+
+    /**
+     * Gets the command description.
+     *
+     * @return the command description
+     */
+    @NonNull
+    String description();
+
+    /**
+     * Gets the permission node associated with
+     * this command.
+     *
+     * @return the permission node for this command
+     */
+    @NonNull
+    String permission();
+
+    /**
+     * Gets the aliases for this command.
+     *
+     * @return the aliases for this command
+     */
+    @NonNull
+    List<String> aliases();
+
+    /**
+     * Gets if this command is designed to be used only by server operators.
+     *
+     * @return if this command is designated to be used only by server operators.
+     */
+    boolean isSuggestedOpOnly();
+
+    /**
+     * Gets if this command is executable on console.
+     *
+     * @return if this command is executable on console
+     */
+    boolean isExecutableOnConsole();
+
+    /**
+     * Gets the subcommands associated with this
+     * command. Mainly used within the Geyser Standalone
+     * GUI to know what subcommands are supported.
+     *
+     * @return the subcommands associated with this command
+     */
+    @NonNull
+    default List<String> subCommands() {
+        return Collections.emptyList();
+    }
+
+    /**
+     * Used to send a deny message to Java players if this command can only be used by Bedrock players.
+     *
+     * @return true if this command can only be used by Bedrock players.
+     */
+    default boolean isBedrockOnly() {
+        return false;
+    }
+
+    /**
+     * Creates a new {@link Command.Builder} used to construct commands.
+     *
+     * @param extension the extension
+     * @param <T> the source type
+     * @return a new command builder used to construct commands
+     */
+    static <T extends CommandSource> Command.Builder<T> builder(@NonNull Extension extension) {
+        return GeyserApi.api().provider(Builder.class, extension);
+    }
+
+    interface Builder<T extends CommandSource> {
+
+        /**
+         * Defines the source type to use for this command.
+         * <p>
+         * Command source types can be anything that extend
+         * {@link CommandSource}, such as {@link GeyserConnection}.
+         * This will guarantee that the source used in the executor
+         * is an instance of this source.
+         *
+         * @param sourceType the source type
+         * @return the builder
+         */
+        Builder<T> source(@NonNull Class<? extends T> sourceType);
+
+        /**
+         * Sets the command name.
+         *
+         * @param name the command name
+         * @return the builder
+         */
+        Builder<T> name(@NonNull String name);
+
+        /**
+         * Sets the command description.
+         *
+         * @param description the command description
+         * @return the builder
+         */
+        Builder<T> description(@NonNull String description);
+
+        /**
+         * Sets the permission node.
+         *
+         * @param permission the permission node
+         * @return the builder
+         */
+        Builder<T> permission(@NonNull String permission);
+
+        /**
+         * Sets the aliases.
+         *
+         * @param aliases the aliases
+         * @return the builder
+         */
+        Builder<T> aliases(@NonNull List<String> aliases);
+
+        /**
+         * Sets if this command is designed to be used only by server operators.
+         *
+         * @param suggestedOpOnly if this command is designed to be used only by server operators
+         * @return the builder
+         */
+        Builder<T> suggestedOpOnly(boolean suggestedOpOnly);
+
+        /**
+         * Sets if this command is executable on console.
+         *
+         * @param executableOnConsole if this command is executable on console
+         * @return the builder
+         */
+        Builder<T> executableOnConsole(boolean executableOnConsole);
+
+        /**
+         * Sets the subcommands.
+         *
+         * @param subCommands the subcommands
+         * @return the builder
+         */
+        Builder<T> subCommands(@NonNull List<String> subCommands);
+
+        /**
+         * Sets if this command is bedrock only.
+         *
+         * @param bedrockOnly if this command is bedrock only
+         * @return the builder
+         */
+        Builder<T> bedrockOnly(boolean bedrockOnly);
+
+        /**
+         * Sets the {@link CommandExecutor} for this command.
+         *
+         * @param executor the command executor
+         * @return the builder
+         */
+        Builder<T> executor(@NonNull CommandExecutor<T> executor);
+
+        /**
+         * Builds the command.
+         *
+         * @return the command
+         */
+        @NonNull
+        Command build();
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/command/CommandExecutor.java b/api/geyser/src/main/java/org/geysermc/geyser/api/command/CommandExecutor.java
new file mode 100644
index 000000000..12a54ee90
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/command/CommandExecutor.java
@@ -0,0 +1,45 @@
+/*
+ * 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.api.command;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * Handles executing a command.
+ *
+ * @param <T> the command source
+ */
+public interface CommandExecutor<T extends CommandSource> {
+    /**
+     * Executes the given {@link Command} with the given
+     * {@link CommandSource}.
+     *
+     * @param source the command source
+     * @param command the command
+     * @param args the arguments
+     */
+    void execute(@NonNull T source, @NonNull Command command, @NonNull String[] args);
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/command/CommandSource.java b/api/geyser/src/main/java/org/geysermc/geyser/api/command/CommandSource.java
new file mode 100644
index 000000000..45276e2c4
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/command/CommandSource.java
@@ -0,0 +1,81 @@
+/*
+ * 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.api.command;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * Represents an instance capable of sending commands.
+ */
+public interface CommandSource {
+
+    /**
+     * The name of the command source.
+     *
+     * @return the name of the command source
+     */
+    String name();
+
+    /**
+     * Sends the given message to the command source
+     *
+     * @param message the message to send
+     */
+    void sendMessage(@NonNull String message);
+
+    /**
+     * Sends the given messages to the command source
+     *
+     * @param messages the messages to send
+     */
+    default void sendMessage(String[] messages) {
+        for (String message : messages) {
+            sendMessage(message);
+        }
+    }
+
+    /**
+     * If this source is the console.
+     *
+     * @return true if this source is the console
+     */
+    boolean isConsole();
+
+    /**
+     * Returns the locale of the command source.
+     *
+     * @return the locale of the command source.
+     */
+    String locale();
+
+    /**
+     * Checks if this command source has the given permission
+     *
+     * @param permission The permission node to check
+     * @return true if this command source has a permission
+     */
+    boolean hasPermission(String permission);
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java b/api/geyser/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java
index 79260ac95..13fd60407 100644
--- a/api/geyser/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java
@@ -25,10 +25,11 @@
 
 package org.geysermc.geyser.api.connection;
 
-import org.geysermc.api.session.Connection;
+import org.geysermc.api.connection.Connection;
+import org.geysermc.geyser.api.command.CommandSource;
 
 /**
- * Represents a player session used in Geyser.
+ * Represents a player connection used in Geyser.
  */
-public interface GeyserConnection extends Connection {
+public interface GeyserConnection extends Connection, CommandSource {
 }
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventBus.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventBus.java
new file mode 100644
index 000000000..801bfa45f
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventBus.java
@@ -0,0 +1,43 @@
+/*
+ * 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.api.event;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.event.bus.OwnedEventBus;
+import org.geysermc.geyser.api.extension.Extension;
+
+import java.util.Set;
+
+/**
+ * Represents a bus capable of subscribing
+ * or "listening" to events and firing them.
+ */
+public interface EventBus<R extends EventRegistrar> extends OwnedEventBus<R, Event, EventSubscriber<R, ? extends Event>> {
+    @Override
+    @NonNull
+    <T extends Event> Set<? extends EventSubscriber<R, T>> subscribers(@NonNull Class<T> eventClass);
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventRegistrar.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventRegistrar.java
new file mode 100644
index 000000000..064dd55f6
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventRegistrar.java
@@ -0,0 +1,47 @@
+/*
+ * 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.api.event;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.GeyserApi;
+
+/**
+ * Represents an owner for an event that allows it
+ * to be registered through an {@link EventBus}.
+ */
+public interface EventRegistrar {
+
+    /**
+     * Creates an {@link EventRegistrar} instance.
+     *
+     * @param object the object to wrap around
+     * @return an event registrar instance
+     */
+    @NonNull
+    static EventRegistrar of(@NonNull Object object) {
+        return GeyserApi.api().provider(EventRegistrar.class, object);
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventSubscriber.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventSubscriber.java
new file mode 100644
index 000000000..7f91d09a3
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/EventSubscriber.java
@@ -0,0 +1,40 @@
+/*
+ * 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.api.event;
+
+import org.geysermc.event.Event;
+import org.geysermc.event.subscribe.OwnedSubscriber;
+import org.geysermc.geyser.api.extension.Extension;
+
+/**
+ * Represents a subscribed listener to a {@link Event}. Wraps around
+ * the event and is capable of unsubscribing from the event or give
+ * information about it.
+ *
+ * @param <T> the class of the event
+ */
+public interface EventSubscriber<R extends EventRegistrar, T extends Event> extends OwnedSubscriber<R, T> {
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/ExtensionEventBus.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/ExtensionEventBus.java
new file mode 100644
index 000000000..a58d35891
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/ExtensionEventBus.java
@@ -0,0 +1,41 @@
+/*
+ * 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.api.event;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.extension.Extension;
+
+import java.util.Set;
+
+/**
+ * An {@link EventBus} with additional methods that implicitly
+ * set the extension instance.
+ */
+public interface ExtensionEventBus extends org.geysermc.event.bus.EventBus<Event, EventSubscriber<Extension, ? extends Event>> {
+    @Override
+    @NonNull <T extends Event> Set<? extends EventSubscriber<EventRegistrar, T>> subscribers(@NonNull Class<T> eventClass);
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/ExtensionEventSubscriber.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/ExtensionEventSubscriber.java
new file mode 100644
index 000000000..9c5fffa2f
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/ExtensionEventSubscriber.java
@@ -0,0 +1,32 @@
+/*
+ * 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.api.event;
+
+import org.geysermc.event.Event;
+import org.geysermc.event.subscribe.Subscriber;
+
+public interface ExtensionEventSubscriber<T extends Event> extends Subscriber<T> {
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/connection/ConnectionEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/connection/ConnectionEvent.java
new file mode 100644
index 000000000..158f14d53
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/connection/ConnectionEvent.java
@@ -0,0 +1,51 @@
+/*
+ * 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.api.event.connection;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+
+/**
+ * An event that contains a {@link GeyserConnection}.
+ */
+public abstract class ConnectionEvent implements Event {
+    private final GeyserConnection connection;
+
+    public ConnectionEvent(@NonNull GeyserConnection connection) {
+        this.connection = connection;
+    }
+
+    /**
+     * Gets the {@link GeyserConnection}.
+     *
+     * @return the connection
+     */
+    @NonNull
+    public GeyserConnection connection() {
+        return this.connection;
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/downstream/ServerDefineCommandsEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/downstream/ServerDefineCommandsEvent.java
new file mode 100644
index 000000000..e46492b36
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/downstream/ServerDefineCommandsEvent.java
@@ -0,0 +1,85 @@
+/*
+ * 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.api.event.downstream;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Cancellable;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+import org.geysermc.geyser.api.event.connection.ConnectionEvent;
+
+import java.util.Set;
+
+/**
+ * Called when the Java server defines the commands available on the server.
+ * <br>
+ * This event is mapped to the existence of Brigadier on the server.
+ */
+public class ServerDefineCommandsEvent extends ConnectionEvent implements Cancellable {
+    private final Set<? extends CommandInfo> commands;
+    private boolean cancelled;
+
+    public ServerDefineCommandsEvent(@NonNull GeyserConnection connection, @NonNull Set<? extends CommandInfo> commands) {
+        super(connection);
+        this.commands = commands;
+    }
+
+    /**
+     * A collection of commands sent from the server. Any element in this collection can be removed, but no element can
+     * be added.
+     *
+     * @return a collection of the commands sent over
+     */
+    @NonNull
+    public Set<? extends CommandInfo> commands() {
+        return this.commands;
+    }
+
+    @Override
+    public boolean isCancelled() {
+        return this.cancelled;
+    }
+
+    @Override
+    public void setCancelled(boolean cancelled) {
+        this.cancelled = cancelled;
+    }
+
+    public interface CommandInfo {
+        /**
+         * Gets the name of the command.
+         *
+         * @return the name of the command
+         */
+        String name();
+
+        /**
+         * Gets the description of the command.
+         *
+         * @return the description of the command
+         */
+        String description();
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCommandsEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCommandsEvent.java
new file mode 100644
index 000000000..77d5efa65
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCommandsEvent.java
@@ -0,0 +1,57 @@
+/*
+ * 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.api.event.lifecycle;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.command.Command;
+
+import java.util.Map;
+
+/**
+ * Called when commands are defined within Geyser.
+ *
+ * This event allows you to register new commands using the {@link #register(Command)}
+ * method and retrieve the default commands defined.
+ */
+public interface GeyserDefineCommandsEvent extends Event {
+
+    /**
+     * Registers the given {@link Command} into the Geyser
+     * command manager.
+     *
+     * @param command the command to register
+     */
+    void register(@NonNull Command command);
+
+    /**
+     * Gets all the registered built-in {@link Command}s.
+     *
+     * @return all the registered built-in commands
+     */
+    @NonNull
+    Map<String, Command> commands();
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java
new file mode 100644
index 000000000..0957b8551
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java
@@ -0,0 +1,76 @@
+/*
+ * 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.api.event.lifecycle;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Called on Geyser's startup when looking for custom items. Custom items must be registered through this event.
+ *
+ * This event will not be called if the "add non-Bedrock items" setting is disabled in the Geyser config.
+ */
+public interface GeyserDefineCustomItemsEvent extends Event {
+    /**
+     * Gets a multimap of all the already registered custom items indexed by the item's extended java item's identifier.
+     *
+     * @return a multimap of all the already registered custom items
+     */
+    @NonNull
+    Map<String, Collection<CustomItemData>> getExistingCustomItems();
+
+    /**
+     * Gets the list of the already registered non-vanilla custom items.
+     *
+     * @return the list of the already registered non-vanilla custom items
+     */
+    @NonNull
+    List<NonVanillaCustomItemData> getExistingNonVanillaCustomItems();
+
+    /**
+     * Registers a custom item with a base Java item. This is used to register items with custom textures and properties
+     * based on NBT data.
+     *
+     * @param identifier the base (java) item
+     * @param customItemData the custom item data to register
+     * @return if the item was registered
+     */
+    boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData);
+
+    /**
+     * Registers a custom item with no base item. This is used for mods.
+     *
+     * @param customItemData the custom item data to register
+     * @return if the item was registered
+     */
+    boolean register(@NonNull NonVanillaCustomItemData customItemData);
+}
\ No newline at end of file
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java
new file mode 100644
index 000000000..e9b283ecb
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java
@@ -0,0 +1,40 @@
+/*
+ * 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.api.event.lifecycle;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Called when resource packs are loaded within Geyser.
+ *
+ * @param resourcePacks a mutable list of the currently listed resource packs
+ */
+public record GeyserLoadResourcePacksEvent(@NonNull List<Path> resourcePacks) implements Event {
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserPostInitializeEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserPostInitializeEvent.java
new file mode 100644
index 000000000..8d145f615
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserPostInitializeEvent.java
@@ -0,0 +1,41 @@
+/*
+ * 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.api.event.lifecycle;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.extension.ExtensionManager;
+
+/**
+ * Called when Geyser has completed initializing.
+ *
+ * @param extensionManager the extension manager
+ * @param eventBus the event bus
+ */
+public record GeyserPostInitializeEvent(@NonNull ExtensionManager extensionManager, @NonNull EventBus<EventRegistrar> eventBus) implements Event {
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserPreInitializeEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserPreInitializeEvent.java
new file mode 100644
index 000000000..8be89dafd
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserPreInitializeEvent.java
@@ -0,0 +1,41 @@
+/*
+ * 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.api.event.lifecycle;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.extension.ExtensionManager;
+
+/**
+ * Called when Geyser is starting to initialize.
+ *
+ * @param extensionManager the extension manager
+ * @param eventBus the event bus
+ */
+public record GeyserPreInitializeEvent(@NonNull ExtensionManager extensionManager, @NonNull EventBus<EventRegistrar> eventBus) implements Event {
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserShutdownEvent.java b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserShutdownEvent.java
new file mode 100644
index 000000000..7793ef997
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserShutdownEvent.java
@@ -0,0 +1,38 @@
+/*
+ * 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.api.event.lifecycle;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.extension.ExtensionManager;
+
+/**
+ * Called when Geyser is shutting down.
+ */
+public record GeyserShutdownEvent(@NonNull ExtensionManager extensionManager, @NonNull EventBus<EventRegistrar> eventBus) implements Event {
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/Extension.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/Extension.java
new file mode 100644
index 000000000..33fc159de
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/Extension.java
@@ -0,0 +1,139 @@
+/*
+ * 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.api.extension;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.api.GeyserApiBase;
+import org.geysermc.geyser.api.GeyserApi;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.event.ExtensionEventBus;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+/**
+ * Represents an extension within Geyser.
+ */
+public interface Extension extends EventRegistrar {
+
+    /**
+     * Gets if the extension is enabled
+     *
+     * @return true if the extension is enabled
+     */
+    default boolean isEnabled() {
+        return this.extensionLoader().isEnabled(this);
+    }
+
+    /**
+     * Enables or disables the extension
+     *
+     * @param enabled if the extension should be enabled
+     */
+    default void setEnabled(boolean enabled) {
+        this.extensionLoader().setEnabled(this, enabled);
+    }
+
+    /**
+     * Gets the extension's data folder
+     *
+     * @return the extension's data folder
+     */
+    @NonNull
+    default Path dataFolder() {
+        return this.extensionLoader().dataFolder(this);
+    }
+
+    /**
+     * Gets the {@link ExtensionEventBus}.
+     *
+     * @return the extension event bus
+     */
+    @NonNull
+    default ExtensionEventBus eventBus() {
+        return this.extensionLoader().eventBus(this);
+    }
+
+    /**
+     * Gets the {@link ExtensionManager}.
+     *
+     * @return the extension manager
+     */
+    @NonNull
+    default ExtensionManager extensionManager() {
+        return this.geyserApi().extensionManager();
+    }
+
+    /**
+     * Gets the extension's name
+     *
+     * @return the extension's name
+     */
+    @NonNull
+    default String name() {
+        return this.description().name();
+    }
+
+    /**
+     * Gets this extension's {@link ExtensionDescription}.
+     *
+     * @return the extension's description
+     */
+    @NonNull
+    default ExtensionDescription description() {
+        return this.extensionLoader().description(this);
+    }
+
+    /**
+     * Gets the extension's logger
+     *
+     * @return the extension's logger
+     */
+    @NonNull
+    default ExtensionLogger logger() {
+        return this.extensionLoader().logger(this);
+    }
+
+    /**
+     * Gets the {@link ExtensionLoader}.
+     *
+     * @return the extension loader
+     */
+    @NonNull
+    default ExtensionLoader extensionLoader() {
+        return Objects.requireNonNull(this.extensionManager().extensionLoader());
+    }
+
+    /**
+     * Gets the {@link GeyserApiBase} instance
+     *
+     * @return the geyser api instance
+     */
+    @NonNull
+    default GeyserApi geyserApi() {
+        return GeyserApi.api();
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionDescription.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionDescription.java
new file mode 100644
index 000000000..2df3ee815
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionDescription.java
@@ -0,0 +1,106 @@
+/*
+ * 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.api.extension;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+import java.util.List;
+
+/**
+ * Represents the description of an {@link Extension}.
+ */
+public interface ExtensionDescription {
+
+    /**
+     * Gets the extension's id.
+     *
+     * @return the extension's id
+     */
+    @NonNull
+    String id();
+
+    /**
+     * Gets the extension's name.
+     *
+     * @return the extension's name
+     */
+    @NonNull
+    String name();
+
+    /**
+     * Gets the extension's main class.
+     *
+     * @return the extension's main class
+     */
+    @NonNull
+    String main();
+
+    /**
+     * Gets the extension's major api version
+     *
+     * @return the extension's major api version
+     */
+    int majorApiVersion();
+
+    /**
+     * Gets the extension's minor api version
+     *
+     * @return the extension's minor api version
+     */
+    int minorApiVersion();
+
+    /**
+     * Gets the extension's patch api version
+     *
+     * @return the extension's patch api version
+     */
+    int patchApiVersion();
+
+    /**
+     * Gets the extension's api version.
+     *
+     * @return the extension's api version
+     */
+    default String apiVersion() {
+        return majorApiVersion() + "." + minorApiVersion() + "." + patchApiVersion();
+    }
+
+    /**
+     * Gets the extension's description.
+     *
+     * @return the extension's description
+     */
+    @NonNull
+    String version();
+
+    /**
+     * Gets the extension's authors.
+     *
+     * @return the extension's authors
+     */
+    @NonNull
+    List<String> authors();
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionLoader.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionLoader.java
new file mode 100644
index 000000000..30414d500
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionLoader.java
@@ -0,0 +1,105 @@
+/*
+ * 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.api.extension;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.event.ExtensionEventBus;
+
+import java.nio.file.Path;
+
+/**
+ * The extension loader is responsible for loading, unloading, enabling and disabling extensions
+ */
+public abstract class ExtensionLoader {
+    /**
+     * Gets if the given {@link Extension} is enabled.
+     *
+     * @param extension the extension
+     * @return if the extension is enabled
+     */
+    protected abstract boolean isEnabled(@NonNull Extension extension);
+
+    /**
+     * Sets if the given {@link Extension} is enabled.
+     *
+     * @param extension the extension to enable
+     * @param enabled if the extension should be enabled
+     */
+    protected abstract void setEnabled(@NonNull Extension extension, boolean enabled);
+
+    /**
+     * Gets the given {@link Extension}'s data folder.
+     *
+     * @param extension the extension
+     * @return the data folder of the given extension
+     */
+    @NonNull
+    protected abstract Path dataFolder(@NonNull Extension extension);
+
+    /**
+     * Gets the given {@link Extension}'s {@link ExtensionDescription}.
+     *
+     * @param extension the extension
+     * @return the description of the given extension
+     */
+    @NonNull
+    protected abstract ExtensionDescription description(@NonNull Extension extension);
+
+    /**
+     * Gets the given {@link Extension}'s {@link ExtensionEventBus}.
+     *
+     * @param extension the extension
+     * @return the extension's event bus
+     */
+    @NonNull
+    protected abstract ExtensionEventBus eventBus(@NonNull Extension extension);
+
+    /**
+     * Gets the {@link ExtensionLogger} for the given {@link Extension}.
+     *
+     * @param extension the extension
+     * @return the extension logger for the given extension
+     */
+    @NonNull
+    protected abstract ExtensionLogger logger(@NonNull Extension extension);
+
+    /**
+     * Loads all extensions.
+     *
+     * @param extensionManager the extension manager
+     */
+    protected abstract void loadAllExtensions(@NonNull ExtensionManager extensionManager);
+
+    /**
+     * Registers the given {@link Extension} with the given {@link ExtensionManager}.
+     *
+     * @param extension the extension
+     * @param extensionManager the extension manager
+     */
+    protected void register(@NonNull Extension extension, @NonNull ExtensionManager extensionManager) {
+        extensionManager.register(extension);
+    }
+}
\ No newline at end of file
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionLogger.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionLogger.java
new file mode 100644
index 000000000..17e108455
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionLogger.java
@@ -0,0 +1,94 @@
+/*
+ * 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.api.extension;
+
+/**
+ * This is the Geyser extension logger
+ */
+public interface ExtensionLogger {
+    /**
+     * Get the logger prefix
+     *
+     * @return the logger prefix
+     */
+    String prefix();
+
+    /**
+     * Logs a severe message to console
+     *
+     * @param message the message to log
+     */
+    void severe(String message);
+
+    /**
+     * Logs a severe message and an exception to console
+     *
+     * @param message the message to log
+     * @param error the error to throw
+     */
+    void severe(String message, Throwable error);
+
+    /**
+     * Logs an error message to console
+     *
+     * @param message the message to log
+     */
+    void error(String message);
+
+    /**
+     * Logs an error message and an exception to console
+     *
+     * @param message the message to log
+     * @param error the error to throw
+     */
+    void error(String message, Throwable error);
+
+    /**
+     * Logs a warning message to console
+     *
+     * @param message the message to log
+     */
+    void warning(String message);
+
+    /**
+     * Logs an info message to console
+     *
+     * @param message the message to log
+     */
+    void info(String message);
+
+    /**
+     * Logs a debug message to console
+     *
+     * @param message the message to log
+     */
+    void debug(String message);
+
+    /**
+     * If debug is enabled for this logger
+     */
+    boolean isDebug();
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionManager.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionManager.java
new file mode 100644
index 000000000..a9d0d7376
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/ExtensionManager.java
@@ -0,0 +1,90 @@
+/*
+ * 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.api.extension;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.Collection;
+
+/**
+ * Manages Geyser {@link Extension}s
+ */
+public abstract class ExtensionManager {
+
+    /**
+     * Gets an extension with the given name.
+     *
+     * @param name the name of the extension
+     * @return an extension with the given name
+     */
+    @Nullable
+    public abstract Extension extension(@NonNull String name);
+
+    /**
+     * Enables the given {@link Extension}.
+     *
+     * @param extension the extension to enable
+     */
+    public abstract void enable(@NonNull Extension extension);
+
+    /**
+     * Disables the given {@link Extension}.
+     *
+     * @param extension the extension to disable
+     */
+    public abstract void disable(@NonNull Extension extension);
+
+    /**
+     * Gets all the {@link Extension}s currently loaded.
+     *
+     * @return all the extensions currently loaded
+     */
+    @NonNull
+    public abstract Collection<Extension> extensions();
+
+    /**
+     * Gets the {@link ExtensionLoader}.
+     *
+     * @return the extension loader
+     */
+    @Nullable
+    public abstract ExtensionLoader extensionLoader();
+
+    /**
+     * Registers an {@link Extension} with the given {@link ExtensionLoader}.
+     *
+     * @param extension the extension
+     */
+    public abstract void register(@NonNull Extension extension);
+
+    /**
+     * Loads all extensions from the given {@link ExtensionLoader}.
+     */
+    protected final void loadAllExtensions(@NonNull ExtensionLoader extensionLoader) {
+        extensionLoader.loadAllExtensions(this);
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/exception/InvalidDescriptionException.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/exception/InvalidDescriptionException.java
new file mode 100644
index 000000000..1fe88e9e9
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/exception/InvalidDescriptionException.java
@@ -0,0 +1,43 @@
+/*
+ * 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.api.extension.exception;
+
+/**
+ * Thrown when an extension's description is invalid.
+ */
+public class InvalidDescriptionException extends Exception {
+    public InvalidDescriptionException(Throwable cause) {
+        super(cause);
+    }
+
+    public InvalidDescriptionException(String message) {
+        super(message);
+    }
+
+    public InvalidDescriptionException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/extension/exception/InvalidExtensionException.java b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/exception/InvalidExtensionException.java
new file mode 100644
index 000000000..7fb6b6922
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/extension/exception/InvalidExtensionException.java
@@ -0,0 +1,43 @@
+/*
+ * 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.api.extension.exception;
+
+/**
+ * Thrown when an extension is invalid.
+ */
+public class InvalidExtensionException extends Exception {
+    public InvalidExtensionException(Throwable cause) {
+        super(cause);
+    }
+
+    public InvalidExtensionException(String message) {
+        super(message);
+    }
+
+    public InvalidExtensionException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java
new file mode 100644
index 000000000..17763fb77
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java
@@ -0,0 +1,109 @@
+/*
+ * 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.api.item.custom;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.api.GeyserApi;
+
+/**
+ * This is used to store data for a custom item.
+ */
+public interface CustomItemData {
+    /**
+     * Gets the item's name.
+     *
+     * @return the item's name
+     */
+    @NonNull String name();
+
+    /**
+     * Gets the custom item options of the item.
+     *
+     * @return the custom item options of the item.
+     */
+    CustomItemOptions customItemOptions();
+
+    /**
+     * Gets the item's display name. By default, this is the item's name.
+     *
+     * @return the item's display name
+     */
+    @NonNull String displayName();
+
+    /**
+     * Gets the item's icon. By default, this is the item's name.
+     *
+     * @return the item's icon
+     */
+    @NonNull String icon();
+
+    /**
+     * Gets if the item is allowed to be put into the offhand.
+     *
+     * @return true if the item is allowed to be used in the offhand, false otherwise
+     */
+    boolean allowOffhand();
+
+    /**
+     * Gets the item's texture size. This is to resize the item if the texture is not 16x16.
+     *
+     * @return the item's texture size
+     */
+    int textureSize();
+
+    /**
+     * Gets the item's render offsets. If it is null, the item will be rendered normally, with no offsets.
+     *
+     * @return the item's render offsets
+     */
+    @Nullable CustomRenderOffsets renderOffsets();
+
+    static CustomItemData.Builder builder() {
+        return GeyserApi.api().provider(CustomItemData.Builder.class);
+    }
+
+    interface Builder {
+        /**
+         * Will also set the display name and icon to the provided parameter, if it is currently not set.
+         */
+        Builder name(@NonNull String name);
+
+        Builder customItemOptions(@NonNull CustomItemOptions customItemOptions);
+
+        Builder displayName(@NonNull String displayName);
+
+        Builder icon(@NonNull String icon);
+
+        Builder allowOffhand(boolean allowOffhand);
+
+        Builder textureSize(int textureSize);
+
+        Builder renderOffsets(@Nullable CustomRenderOffsets renderOffsets);
+
+        CustomItemData build();
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java
new file mode 100644
index 000000000..ec26a6e37
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java
@@ -0,0 +1,83 @@
+/*
+ * 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.api.item.custom;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.GeyserApi;
+import org.geysermc.geyser.api.util.TriState;
+
+import java.util.OptionalInt;
+
+/**
+ * This class represents the different ways you can register custom items
+ */
+public interface CustomItemOptions {
+    /**
+     * Gets if the item should be unbreakable.
+     *
+     * @return if the item should be unbreakable
+     */
+    @NonNull TriState unbreakable();
+
+    /**
+     * Gets the item's custom model data predicate.
+     *
+     * @return the item's custom model data
+     */
+    @NonNull OptionalInt customModelData();
+
+    /**
+     * Gets the item's damage predicate.
+     *
+     * @return the item's damage predicate
+     */
+    @NonNull OptionalInt damagePredicate();
+
+    /**
+     * Checks if the item has at least one option set
+     *
+     * @return true if the item at least one options set
+     */
+    default boolean hasCustomItemOptions() {
+        return this.unbreakable() != TriState.NOT_SET ||
+                this.customModelData().isPresent() ||
+                this.damagePredicate().isPresent();
+    }
+
+    static CustomItemOptions.Builder builder() {
+        return GeyserApi.api().provider(CustomItemOptions.Builder.class);
+    }
+
+    interface Builder {
+        Builder unbreakable(boolean unbreakable);
+
+        Builder customModelData(int customModelData);
+
+        Builder damagePredicate(int damagePredicate);
+
+        CustomItemOptions build();
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomRenderOffsets.java b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomRenderOffsets.java
new file mode 100644
index 000000000..f81da0ae2
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/CustomRenderOffsets.java
@@ -0,0 +1,51 @@
+/*
+ * 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.api.item.custom;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * This class is used to store the render offsets of custom items.
+ */
+public record CustomRenderOffsets(@Nullable Hand mainHand, @Nullable Hand offhand) {
+    /**
+     * The hand that is used for the offset.
+     */
+    public record Hand(@Nullable Offset firstPerson, @Nullable Offset thirdPerson) {
+    }
+
+    /**
+     * The offset of the item.
+     */
+    public record Offset(@Nullable OffsetXYZ position, @Nullable OffsetXYZ rotation, @Nullable OffsetXYZ scale) {
+    }
+
+    /**
+     * X, Y and Z positions for the offset.
+     */
+    public record OffsetXYZ(float x, float y, float z) {
+    }
+}
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/NonVanillaCustomItemData.java b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/NonVanillaCustomItemData.java
new file mode 100644
index 000000000..d2cef637a
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/item/custom/NonVanillaCustomItemData.java
@@ -0,0 +1,188 @@
+/*
+ * 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.api.item.custom;
+
+import org.checkerframework.checker.index.qual.NonNegative;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.api.GeyserApi;
+
+import java.util.OptionalInt;
+import java.util.Set;
+
+/**
+ * Represents a completely custom item that is not based on an existing vanilla Minecraft item.
+ */
+public interface NonVanillaCustomItemData extends CustomItemData {
+    /**
+     * Gets the java identifier for this item.
+     *
+     * @return The java identifier for this item.
+     */
+    @NonNull String identifier();
+
+    /**
+     * Gets the java item id of the item.
+     *
+     * @return the java item id of the item
+     */
+    @NonNegative int javaId();
+
+    /**
+     * Gets the stack size of the item.
+     *
+     * @return the stack size of the item
+     */
+    @NonNegative int stackSize();
+
+    /**
+     * Gets the max damage of the item.
+     *
+     * @return the max damage of the item
+     */
+    int maxDamage();
+
+    /**
+     * Gets the tool type of the item.
+     *
+     * @return the tool type of the item
+     */
+    @Nullable String toolType();
+
+    /**
+     * Gets the tool tier of the item.
+     *
+     * @return the tool tier of the item
+     */
+    @Nullable String toolTier();
+
+    /**
+     * Gets the armor type of the item.
+     *
+     * @return the armor type of the item
+     */
+    @Nullable String armorType();
+
+    /**
+     * Gets the armor protection value of the item.
+     *
+     * @return the armor protection value of the item
+     */
+    int protectionValue();
+
+    /**
+     * Gets the item's translation string.
+     *
+     * @return the item's translation string
+     */
+    @Nullable String translationString();
+
+    /**
+     * Gets the repair materials of the item.
+     *
+     * @return the repair materials of the item
+     */
+    @Nullable Set<String> repairMaterials();
+
+    /**
+     * Gets the item's creative category, or tab id.
+     *
+     * @return the item's creative category
+     */
+    @NonNull OptionalInt creativeCategory();
+
+    /**
+     * Gets the item's creative group.
+     *
+     * @return the item's creative group
+     */
+    @Nullable String creativeGroup();
+
+    /**
+     * Gets if the item is a hat. This is used to determine if the item should be rendered on the player's head, and
+     * normally allow the player to equip it. This is not meant for armor.
+     *
+     * @return if the item is a hat
+     */
+    boolean isHat();
+
+    /**
+     * Gets if the item is a tool. This is used to set the render type of the item, if the item is handheld.
+     *
+     * @return if the item is a tool
+     */
+    boolean isTool();
+
+    static NonVanillaCustomItemData.Builder builder() {
+        return GeyserApi.api().provider(NonVanillaCustomItemData.Builder.class);
+    }
+
+    interface Builder extends CustomItemData.Builder {
+        Builder name(@NonNull String name);
+
+        Builder identifier(@NonNull String identifier);
+
+        Builder javaId(@NonNegative int javaId);
+
+        Builder stackSize(@NonNegative int stackSize);
+
+        Builder maxDamage(int maxDamage);
+
+        Builder toolType(@Nullable String toolType);
+
+        Builder toolTier(@Nullable String toolTier);
+
+        Builder armorType(@Nullable String armorType);
+
+        Builder protectionValue(int protectionValue);
+
+        Builder translationString(@Nullable String translationString);
+
+        Builder repairMaterials(@Nullable Set<String> repairMaterials);
+
+        Builder creativeCategory(int creativeCategory);
+
+        Builder creativeGroup(@Nullable String creativeGroup);
+
+        Builder hat(boolean isHat);
+
+        Builder tool(boolean isTool);
+
+        @Override
+        Builder displayName(@NonNull String displayName);
+
+        @Override
+        Builder allowOffhand(boolean allowOffhand);
+
+        @Override
+        Builder textureSize(int textureSize);
+
+        @Override
+        Builder renderOffsets(@Nullable CustomRenderOffsets renderOffsets);
+
+        NonVanillaCustomItemData build();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/session/auth/AuthType.java b/api/geyser/src/main/java/org/geysermc/geyser/api/network/AuthType.java
similarity index 69%
rename from core/src/main/java/org/geysermc/geyser/session/auth/AuthType.java
rename to api/geyser/src/main/java/org/geysermc/geyser/api/network/AuthType.java
index 1edbd0f29..3176f3384 100644
--- a/core/src/main/java/org/geysermc/geyser/session/auth/AuthType.java
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/network/AuthType.java
@@ -23,26 +23,24 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.geyser.session.auth;
+package org.geysermc.geyser.api.network;
 
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.databind.DeserializationContext;
-import com.fasterxml.jackson.databind.JsonDeserializer;
-import lombok.Getter;
+import java.util.Locale;
 
-import java.io.IOException;
-
-@Getter
+/**
+ * The authentication types that a Java server can be on connection.
+ */
 public enum AuthType {
     OFFLINE,
     ONLINE,
+    /**
+     * The internal name for connecting to an online mode server without needing a Java account. The presence of this
+     * authentication type does not necessarily mean the Floodgate plugin is installed; it only means that this
+     * authentication type will be attempted.
+     */
     FLOODGATE;
 
-    public static final AuthType[] VALUES = values();
-
-    public static AuthType getById(int id) {
-        return id < VALUES.length ? VALUES[id] : OFFLINE;
-    }
+    private static final AuthType[] VALUES = values();
 
     /**
      * Convert the AuthType string (from config) to the enum, ONLINE on fail
@@ -52,7 +50,7 @@ public enum AuthType {
      * @return The converted AuthType
      */
     public static AuthType getByName(String name) {
-        String upperCase = name.toUpperCase();
+        String upperCase = name.toUpperCase(Locale.ROOT);
         for (AuthType type : VALUES) {
             if (type.name().equals(upperCase)) {
                 return type;
@@ -60,11 +58,4 @@ public enum AuthType {
         }
         return ONLINE;
     }
-
-    public static class Deserializer extends JsonDeserializer<AuthType> {
-        @Override
-        public AuthType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
-            return getByName(p.getValueAsString());
-        }
-    }
 }
\ No newline at end of file
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/network/BedrockListener.java b/api/geyser/src/main/java/org/geysermc/geyser/api/network/BedrockListener.java
new file mode 100644
index 000000000..61fe286aa
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/network/BedrockListener.java
@@ -0,0 +1,77 @@
+/*
+ * 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.api.network;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+/**
+ * The listener that handles connections from Minecraft:
+ * Bedrock Edition.
+ */
+public interface BedrockListener {
+
+    /**
+     * Gets the address used for listening for Bedrock
+     * connections from.
+     *
+     * @return the listening address
+     */
+    @NonNull
+    String address();
+
+    /**
+     * Gets the port used for listening for Bedrock
+     * connections from.
+     *
+     * @return the listening port
+     */
+    int port();
+
+    /**
+     * Gets the primary MOTD shown to Bedrock players if a ping passthrough setting is not enabled.
+     * <p>
+     * This is the first line that will be displayed.
+     *
+     * @return the primary MOTD shown to Bedrock players.
+     */
+    String primaryMotd();
+
+    /**
+     * Gets the secondary MOTD shown to Bedrock players if a ping passthrough setting is not enabled.
+     * <p>
+     * This is the second line that will be displayed.
+     *
+     * @return the secondary MOTD shown to Bedrock players.
+     */
+    String secondaryMotd();
+
+    /**
+     * Gets the server name that is sent to Bedrock clients.
+     *
+     * @return the server sent to Bedrock clients
+     */
+    String serverName();
+}
diff --git a/api/base/src/main/java/org/geysermc/api/session/Connection.java b/api/geyser/src/main/java/org/geysermc/geyser/api/network/RemoteServer.java
similarity index 61%
rename from api/base/src/main/java/org/geysermc/api/session/Connection.java
rename to api/geyser/src/main/java/org/geysermc/geyser/api/network/RemoteServer.java
index 3e997912b..8ac5d8a03 100644
--- a/api/base/src/main/java/org/geysermc/api/session/Connection.java
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/network/RemoteServer.java
@@ -23,46 +23,48 @@
  * @link https://github.com/GeyserMC/Geyser
  */
 
-package org.geysermc.api.session;
+package org.geysermc.geyser.api.network;
 
 import org.checkerframework.checker.nullness.qual.NonNull;
-import org.checkerframework.common.value.qual.IntRange;
-
-import java.util.UUID;
 
 /**
- * Represents a player connection.
+ * Represents the Java server that Geyser is connecting to.
  */
-@NonNull
-public interface Connection {
-    /**
-     * Gets the name of the connection.
-     *
-     * @return the name of the connection
-     */
-    String name();
+public interface RemoteServer {
 
     /**
-     * Gets the {@link UUID} of the connection.
+     * Gets the IP address of the remote server.
      *
-     * @return the UUID of the connection
+     * @return the IP address of the remote server
      */
-    UUID uuid();
+    String address();
 
     /**
-     * Gets the XUID of the connection.
+     * Gets the port of the remote server.
      *
-     * @return the XUID of the connection
+     * @return the port of the remote server
      */
-    String xuid();
+    int port();
 
     /**
-     * Transfer the connection to a server. A Bedrock player can successfully transfer to the same server they are
-     * currently playing on.
+     * Gets the protocol version of the remote server.
      *
-     * @param address The address of the server
-     * @param port The port of the server
-     * @return true if the transfer was a success
+     * @return the protocol version of the remote server
      */
-    boolean transfer(@NonNull String address, @IntRange(from = 0, to = 65535) int port);
+    int protocolVersion();
+
+    /**
+     * Gets the Minecraft version of the remote server.
+     *
+     * @return the Minecraft version of the remote server
+     */
+    String minecraftVersion();
+
+    /**
+     * Gets the {@link AuthType} required by the remote server.
+     *
+     * @return the auth type required by the remote server
+     */
+    @NonNull
+    AuthType authType();
 }
diff --git a/api/geyser/src/main/java/org/geysermc/geyser/api/util/TriState.java b/api/geyser/src/main/java/org/geysermc/geyser/api/util/TriState.java
new file mode 100644
index 000000000..457a38e32
--- /dev/null
+++ b/api/geyser/src/main/java/org/geysermc/geyser/api/util/TriState.java
@@ -0,0 +1,83 @@
+/*
+ * 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.api.util;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * This is a way to represent a boolean, but with a non set value added.
+ * This class was inspired by adventure's version https://github.com/KyoriPowered/adventure/blob/main/4/api/src/main/java/net/kyori/adventure/util/TriState.java
+ */
+public enum TriState {
+    /**
+     * Describes a value that is not set, null, or not present.
+     */
+    NOT_SET,
+
+    /**
+     * Describes a true value.
+     */
+    TRUE,
+
+    /**
+     * Describes a false value.
+     */
+    FALSE;
+
+    /**
+     * Converts the TriState to a boolean.
+     *
+     * @return the boolean value of the TriState
+     */
+    public @Nullable Boolean toBoolean() {
+        return switch (this) {
+            case TRUE -> true;
+            case FALSE -> false;
+            default -> null;
+        };
+    }
+
+    /**
+     * Creates a TriState from a boolean.
+     *
+     * @param value the Boolean value
+     * @return the created TriState
+     */
+    public static @NonNull TriState fromBoolean(@Nullable Boolean value) {
+        return value == null ? NOT_SET : fromBoolean(value.booleanValue());
+    }
+
+    /**
+     * Creates a TriState from a primitive boolean.
+     *
+     * @param value the boolean value
+     * @return the created TriState
+     */
+    public @NonNull static TriState fromBoolean(boolean value) {
+        return value ? TRUE : FALSE;
+    }
+}
diff --git a/api/pom.xml b/api/pom.xml
deleted file mode 100644
index 79e999c16..000000000
--- a/api/pom.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>geyser-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-
-    <artifactId>api-parent</artifactId>
-    <packaging>pom</packaging>
-
-    <properties>
-        <maven.compiler.source>16</maven.compiler.source>
-        <maven.compiler.target>16</maven.compiler.target>
-    </properties>
-
-    <modules>
-        <module>base</module>
-        <module>geyser</module>
-    </modules>
-</project>
\ No newline at end of file
diff --git a/bootstrap/bungeecord/build.gradle.kts b/bootstrap/bungeecord/build.gradle.kts
new file mode 100644
index 000000000..9f3b49b67
--- /dev/null
+++ b/bootstrap/bungeecord/build.gradle.kts
@@ -0,0 +1,37 @@
+val bungeeVersion = "a7c6ede";
+
+dependencies {
+    api(projects.core)
+
+    implementation("net.kyori", "adventure-text-serializer-bungeecord", Versions.adventurePlatformVersion)
+}
+
+platformRelocate("net.md_5.bungee.jni")
+platformRelocate("com.fasterxml.jackson")
+platformRelocate("io.netty.channel.kqueue") // This is not used because relocating breaks natives, but we must include it or else we get ClassDefNotFound
+platformRelocate("net.kyori")
+
+// These dependencies are already present on the platform
+provided("com.github.SpigotMC.BungeeCord", "bungeecord-proxy", bungeeVersion)
+
+application {
+    mainClass.set("org.geysermc.geyser.platform.bungeecord.GeyserBungeeMain")
+}
+
+tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
+    archiveBaseName.set("Geyser-BungeeCord")
+
+    dependencies {
+        exclude(dependency("com.google.*:.*"))
+        exclude(dependency("org.yaml:.*"))
+        exclude(dependency("io.netty:netty-transport-native-epoll:.*"))
+        exclude(dependency("io.netty:netty-transport-native-unix-common:.*"))
+        exclude(dependency("io.netty:netty-handler:.*"))
+        exclude(dependency("io.netty:netty-common:.*"))
+        exclude(dependency("io.netty:netty-buffer:.*"))
+        exclude(dependency("io.netty:netty-resolver:.*"))
+        exclude(dependency("io.netty:netty-transport:.*"))
+        exclude(dependency("io.netty:netty-codec:.*"))
+        exclude(dependency("io.netty:netty-resolver-dns:.*"))
+    }
+}
\ No newline at end of file
diff --git a/bootstrap/bungeecord/pom.xml b/bootstrap/bungeecord/pom.xml
deleted file mode 100644
index d71a20f42..000000000
--- a/bootstrap/bungeecord/pom.xml
+++ /dev/null
@@ -1,109 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>bootstrap-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>bootstrap-bungeecord</artifactId>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>core</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- Used for better working with internals without reflection -->
-        <dependency>
-            <groupId>com.github.SpigotMC.BungeeCord</groupId>
-            <artifactId>bungeecord-proxy</artifactId>
-            <version>a7c6ede</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.kyori</groupId>
-            <artifactId>adventure-text-serializer-bungeecord</artifactId>
-            <version>${adventure-platform.version}</version>
-            <scope>compile</scope>
-        </dependency>
-    </dependencies>
-    <build>
-        <finalName>${outputName}-BungeeCord</finalName>
-        <resources>
-            <resource>
-                <directory>src/main/resources/</directory>
-                <filtering>true</filtering>
-            </resource>
-        </resources>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-                <version>3.2.0</version>
-                <configuration>
-                    <archive>
-                        <manifestEntries>
-                            <Main-Class>org.geysermc.geyser.platform.bungeecord.GeyserBungeeMain</Main-Class>
-                        </manifestEntries>
-                    </archive>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-shade-plugin</artifactId>
-                <version>3.3.0</version>
-                <executions>
-                    <execution>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>shade</goal>
-                        </goals>
-                        <configuration>
-                            <relocations>
-                                <relocation>
-                                    <pattern>net.md_5.bungee.jni</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.bungeecord.shaded.jni</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>com.fasterxml.jackson</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.bungeecord.shaded.jackson</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <!-- This is not used because relocating breaks natives, but we must include it
-                                     or else we get ClassDefNotFound -->
-                                    <pattern>io.netty.channel.kqueue</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.bungeecord.shaded.io.netty.channel.kqueue</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>net.kyori</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.bungeecord.shaded.kyori</shadedPattern>
-                                </relocation>
-                            </relocations>
-                        </configuration>
-                    </execution>
-                </executions>
-                <configuration>
-                    <artifactSet>
-                        <excludes>
-                            <exclude>com.google.*:*</exclude>
-                            <exclude>org.yaml:*</exclude>
-                            <exclude>io.netty:netty-transport-native-epoll:*</exclude>
-                            <exclude>io.netty:netty-transport-native-unix-common:*</exclude>
-                            <exclude>io.netty:netty-handler:*</exclude>
-                            <exclude>io.netty:netty-common:*</exclude>
-                            <exclude>io.netty:netty-buffer:*</exclude>
-                            <exclude>io.netty:netty-resolver:*</exclude>
-                            <exclude>io.netty:netty-transport:*</exclude>
-                            <exclude>io.netty:netty-codec:*</exclude>
-                            <exclude>io.netty:netty-resolver-dns:*</exclude>
-                        </excludes>
-                    </artifactSet>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-</project>
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeDumpInfo.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeDumpInfo.java
index 54cb16edb..938e2fc3a 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeDumpInfo.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeDumpInfo.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.platform.bungeecord;
 import lombok.Getter;
 import net.md_5.bungee.api.ProxyServer;
 import net.md_5.bungee.api.plugin.Plugin;
-import org.geysermc.geyser.text.AsteriskSerializer;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
+import org.geysermc.geyser.text.AsteriskSerializer;
 
 import java.util.ArrayList;
 import java.util.Collections;
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java
index 2b1fa10c0..cef430bd6 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java
@@ -39,8 +39,8 @@ import net.md_5.bungee.api.plugin.Listener;
 import net.md_5.bungee.api.plugin.Plugin;
 import net.md_5.bungee.event.EventHandler;
 import net.md_5.bungee.netty.PipelineUtils;
-import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserBootstrap;
+import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.network.netty.GeyserInjector;
 import org.geysermc.geyser.network.netty.LocalServerChannelWrapper;
 import org.geysermc.geyser.network.netty.LocalSession;
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java
index e8d44b02f..13604a3d4 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java
@@ -25,31 +25,39 @@
 
 package org.geysermc.geyser.platform.bungeecord;
 
+import io.netty.channel.Channel;
+import net.md_5.bungee.BungeeCord;
 import net.md_5.bungee.api.config.ListenerInfo;
 import net.md_5.bungee.api.plugin.Plugin;
 import net.md_5.bungee.protocol.ProtocolConstants;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.geysermc.common.PlatformType;
-import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserBootstrap;
-import org.geysermc.geyser.command.CommandManager;
-import org.geysermc.geyser.session.auth.AuthType;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.network.AuthType;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
 import org.geysermc.geyser.ping.GeyserLegacyPingPassthrough;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
-import org.geysermc.geyser.util.FileUtils;
-import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.platform.bungeecord.command.GeyserBungeeCommandExecutor;
 import org.geysermc.geyser.platform.bungeecord.command.GeyserBungeeCommandManager;
-import org.jetbrains.annotations.Nullable;
+import org.geysermc.geyser.text.GeyserLocale;
+import org.geysermc.geyser.util.FileUtils;
 
 import java.io.File;
 import java.io.IOException;
+import java.lang.reflect.Field;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Collection;
+import java.util.Map;
 import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 import java.util.logging.Level;
 
 public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
@@ -63,9 +71,7 @@ public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
     private GeyserImpl geyser;
 
     @Override
-    public void onEnable() {
-        GeyserLocale.init(this);
-
+    public void onLoad() {
         // Copied from ViaVersion.
         // https://github.com/ViaVersion/ViaVersion/blob/b8072aad86695cc8ec6f5e4103e43baf3abf6cc5/bungee/src/main/java/us/myles/ViaVersion/BungeePlugin.java#L43
         try {
@@ -80,6 +86,8 @@ public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
             getLogger().warning("/_____________\\");
         }
 
+        GeyserLocale.init(this);
+
         if (!getDataFolder().exists())
             getDataFolder().mkdir();
 
@@ -95,13 +103,38 @@ public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
             return;
         }
 
+        this.geyserLogger = new GeyserBungeeLogger(getLogger(), geyserConfig.isDebugMode());
+        GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
+
+        this.geyser = GeyserImpl.load(PlatformType.BUNGEECORD, this);
+    }
+
+    @Override
+    public void onEnable() {
+        // Remove this in like a year
+        if (getProxy().getPluginManager().getPlugin("floodgate-bungee") != null) {
+            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.outdated", "https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/"));
+            return;
+        }
+
+        if (geyserConfig.getRemote().authType() == AuthType.FLOODGATE && getProxy().getPluginManager().getPlugin("floodgate") == null) {
+            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.not_installed") + " " + GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.disabling"));
+            return;
+        } else if (geyserConfig.isAutoconfiguredRemote() && getProxy().getPluginManager().getPlugin("floodgate") != null) {
+            // Floodgate installed means that the user wants Floodgate authentication
+            geyserLogger.debug("Auto-setting to Floodgate authentication.");
+            geyserConfig.getRemote().setAuthType(AuthType.FLOODGATE);
+        }
+
+        geyserConfig.loadFloodgate(this);
+
         if (getProxy().getConfig().getListeners().size() == 1) {
             ListenerInfo listener = getProxy().getConfig().getListeners().toArray(new ListenerInfo[0])[0];
 
             InetSocketAddress javaAddr = listener.getHost();
 
             // By default this should be localhost but may need to be changed in some circumstances
-            if (this.geyserConfig.getRemote().getAddress().equalsIgnoreCase("auto")) {
+            if (this.geyserConfig.getRemote().address().equalsIgnoreCase("auto")) {
                 this.geyserConfig.setAutoconfiguredRemote(true);
                 // Don't use localhost if not listening on all interfaces
                 if (!javaAddr.getHostString().equals("0.0.0.0") && !javaAddr.getHostString().equals("")) {
@@ -115,42 +148,63 @@ public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
             }
         }
 
-        this.geyserLogger = new GeyserBungeeLogger(getLogger(), geyserConfig.isDebugMode());
-        GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
+        // Big hack - Bungee does not provide us an event to listen to, so schedule a repeating
+        // task that waits for a field to be filled which is set after the plugin enable
+        // process is complete
+        this.awaitStartupCompletion(0);
+    }
 
-        // Remove this in like a year
-        if (getProxy().getPluginManager().getPlugin("floodgate-bungee") != null) {
-            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.outdated", "https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/"));
+    @SuppressWarnings("unchecked")
+    private void awaitStartupCompletion(int tries) {
+        // After 20 tries give up waiting. This will happen
+        // just after 3 minutes approximately
+        if (tries >= 20) {
+            this.geyserLogger.warning("BungeeCord plugin startup is taking abnormally long, so Geyser is starting now. " +
+                    "If all your plugins are loaded properly, this is a bug! " +
+                    "If not, consider cutting down the amount of plugins on your proxy as it is causing abnormally slow starting times.");
+            this.postStartup();
             return;
         }
 
-        if (geyserConfig.getRemote().getAuthType() == AuthType.FLOODGATE && getProxy().getPluginManager().getPlugin("floodgate") == null) {
-            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.not_installed") + " " + GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.disabling"));
-            return;
-        } else if (geyserConfig.isAutoconfiguredRemote() && getProxy().getPluginManager().getPlugin("floodgate") != null) {
-            // Floodgate installed means that the user wants Floodgate authentication
-            geyserLogger.debug("Auto-setting to Floodgate authentication.");
-            geyserConfig.getRemote().setAuthType(AuthType.FLOODGATE);
+        try {
+            Field listenersField = BungeeCord.getInstance().getClass().getDeclaredField("listeners");
+            listenersField.setAccessible(true);
+
+            Collection<Channel> listeners = (Collection<Channel>) listenersField.get(BungeeCord.getInstance());
+            if (listeners.isEmpty()) {
+                this.getProxy().getScheduler().schedule(this, this::postStartup, tries, TimeUnit.SECONDS);
+            } else {
+                this.awaitStartupCompletion(++tries);
+            }
+        } catch (NoSuchFieldException | IllegalAccessException ex) {
+            ex.printStackTrace();
         }
+    }
 
-        geyserConfig.loadFloodgate(this);
-
-        this.geyser = GeyserImpl.start(PlatformType.BUNGEECORD, this);
+    private void postStartup() {
+        GeyserImpl.start();
 
         this.geyserInjector = new GeyserBungeeInjector(this);
         this.geyserInjector.initializeLocalChannel(this);
 
         this.geyserCommandManager = new GeyserBungeeCommandManager(geyser);
+        this.geyserCommandManager.init();
+
+        this.getProxy().getPluginManager().registerCommand(this, new GeyserBungeeCommandExecutor("geyser", this.geyser, this.geyserCommandManager.getCommands()));
+        for (Map.Entry<Extension, Map<String, Command>> entry : this.geyserCommandManager.extensionCommands().entrySet()) {
+            Map<String, Command> commands = entry.getValue();
+            if (commands.isEmpty()) {
+                continue;
+            }
+
+            this.getProxy().getPluginManager().registerCommand(this, new GeyserBungeeCommandExecutor(entry.getKey().description().id(), this.geyser, commands));
+        }
 
         if (geyserConfig.isLegacyPingPassthrough()) {
             this.geyserBungeePingPassthrough = GeyserLegacyPingPassthrough.init(geyser);
         } else {
             this.geyserBungeePingPassthrough = new GeyserBungeePingPassthrough(getProxy());
         }
-
-        this.getProxy().getPluginManager().registerCommand(this, new GeyserBungeeCommandExecutor(geyser));
-
-        this.getProxy().getPluginManager().registerListener(this, new GeyserBungeeUpdateListener());
     }
 
     @Override
@@ -174,7 +228,7 @@ public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
     }
 
     @Override
-    public CommandManager getGeyserCommandManager() {
+    public GeyserCommandManager getGeyserCommandManager() {
         return this.geyserCommandManager;
     }
 
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java
index bbde8771e..c68839b20 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java
@@ -31,7 +31,7 @@ import net.md_5.bungee.api.plugin.Listener;
 import net.md_5.bungee.event.EventHandler;
 import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.platform.bungeecord.command.BungeeCommandSender;
+import org.geysermc.geyser.platform.bungeecord.command.BungeeCommandSource;
 import org.geysermc.geyser.util.VersionCheckUtils;
 
 public final class GeyserBungeeUpdateListener implements Listener {
@@ -41,7 +41,7 @@ public final class GeyserBungeeUpdateListener implements Listener {
         if (GeyserImpl.getInstance().getConfig().isNotifyOnNewBedrockUpdate()) {
             final ProxiedPlayer player = event.getPlayer();
             if (player.hasPermission(Constants.UPDATE_PERMISSION)) {
-                VersionCheckUtils.checkForGeyserUpdate(() -> new BungeeCommandSender(player));
+                VersionCheckUtils.checkForGeyserUpdate(() -> new BungeeCommandSource(player));
             }
         }
     }
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSource.java
similarity index 91%
rename from bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java
rename to bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSource.java
index dcf5bd689..f65377643 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSource.java
@@ -29,19 +29,19 @@ import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.serializer.bungeecord.BungeeComponentSerializer;
 import net.md_5.bungee.api.chat.TextComponent;
 import net.md_5.bungee.api.connection.ProxiedPlayer;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.Locale;
 
-public class BungeeCommandSender implements CommandSender {
+public class BungeeCommandSource implements GeyserCommandSource {
 
     private final net.md_5.bungee.api.CommandSender handle;
 
-    public BungeeCommandSender(net.md_5.bungee.api.CommandSender handle) {
+    public BungeeCommandSource(net.md_5.bungee.api.CommandSender handle) {
         this.handle = handle;
         // Ensure even Java players' languages are loaded
-        GeyserLocale.loadGeyserLocale(getLocale());
+        GeyserLocale.loadGeyserLocale(this.locale());
     }
 
     @Override
@@ -72,7 +72,7 @@ public class BungeeCommandSender implements CommandSender {
     }
 
     @Override
-    public String getLocale() {
+    public String locale() {
         if (handle instanceof ProxiedPlayer player) {
             Locale locale = player.getLocale();
             if (locale != null) {
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandExecutor.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandExecutor.java
index 5bb323aac..6575f047c 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandExecutor.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandExecutor.java
@@ -30,39 +30,40 @@ import net.md_5.bungee.api.CommandSender;
 import net.md_5.bungee.api.plugin.Command;
 import net.md_5.bungee.api.plugin.TabExecutor;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandExecutor;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandExecutor;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Map;
 
 public class GeyserBungeeCommandExecutor extends Command implements TabExecutor {
-    private final CommandExecutor commandExecutor;
+    private final GeyserCommandExecutor commandExecutor;
 
-    public GeyserBungeeCommandExecutor(GeyserImpl geyser) {
-        super("geyser");
+    public GeyserBungeeCommandExecutor(String name, GeyserImpl geyser, Map<String, org.geysermc.geyser.api.command.Command> commands) {
+        super(name);
 
-        this.commandExecutor = new CommandExecutor(geyser);
+        this.commandExecutor = new GeyserCommandExecutor(geyser, commands);
     }
 
     @Override
     public void execute(CommandSender sender, String[] args) {
-        BungeeCommandSender commandSender = new BungeeCommandSender(sender);
+        BungeeCommandSource commandSender = new BungeeCommandSource(sender);
         GeyserSession session = this.commandExecutor.getGeyserSession(commandSender);
 
         if (args.length > 0) {
             GeyserCommand command = this.commandExecutor.getCommand(args[0]);
             if (command != null) {
-                if (!sender.hasPermission(command.getPermission())) {
-                    String message = GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", commandSender.getLocale());
+                if (!sender.hasPermission(command.permission())) {
+                    String message = GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", commandSender.locale());
 
                     commandSender.sendMessage(ChatColor.RED + message);
                     return;
                 }
                 if (command.isBedrockOnly() && session == null) {
-                    String message = GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.bedrock_only", commandSender.getLocale());
+                    String message = GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.bedrock_only", commandSender.locale());
 
                     commandSender.sendMessage(ChatColor.RED + message);
                     return;
@@ -77,7 +78,7 @@ public class GeyserBungeeCommandExecutor extends Command implements TabExecutor
     @Override
     public Iterable<String> onTabComplete(CommandSender sender, String[] args) {
         if (args.length == 1) {
-            return commandExecutor.tabComplete(new BungeeCommandSender(sender));
+            return commandExecutor.tabComplete(new BungeeCommandSource(sender));
         } else {
             return Collections.emptyList();
         }
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandManager.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandManager.java
index 019544c28..e0fd7a4ac 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandManager.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/GeyserBungeeCommandManager.java
@@ -26,16 +26,16 @@
 package org.geysermc.geyser.platform.bungeecord.command;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.command.GeyserCommandManager;
 
-public class GeyserBungeeCommandManager extends CommandManager {
+public class GeyserBungeeCommandManager extends GeyserCommandManager {
 
     public GeyserBungeeCommandManager(GeyserImpl geyser) {
         super(geyser);
     }
 
     @Override
-    public String getDescription(String command) {
+    public String description(String command) {
         return ""; // no support for command descriptions in bungee
     }
 }
diff --git a/bootstrap/bungeecord/src/main/resources/bungee.yml b/bootstrap/bungeecord/src/main/resources/bungee.yml
index 7390a4623..1e18b8da4 100644
--- a/bootstrap/bungeecord/src/main/resources/bungee.yml
+++ b/bootstrap/bungeecord/src/main/resources/bungee.yml
@@ -1,5 +1,5 @@
 main: org.geysermc.geyser.platform.bungeecord.GeyserBungeePlugin
-name: ${outputName}-BungeeCord
-author: ${project.organization.name}
-website: ${project.organization.url}
-version: ${project.version}
\ No newline at end of file
+name: ${name}-BungeeCord
+author: ${author}
+website: ${url}
+version: ${version}
\ No newline at end of file
diff --git a/bootstrap/pom.xml b/bootstrap/pom.xml
deleted file mode 100644
index 35ec15abe..000000000
--- a/bootstrap/pom.xml
+++ /dev/null
@@ -1,53 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>geyser-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>bootstrap-parent</artifactId>
-    <packaging>pom</packaging>
-
-    <properties>
-        <adventure-platform.version>4.1.2</adventure-platform.version>
-    </properties>
-
-    <repositories>
-        <repository>
-            <id>spigot-public</id>
-            <url>https://hub.spigotmc.org/nexus/content/repositories/public/</url>
-        </repository>
-        <repository>
-            <id>sponge-repo</id>
-            <url>https://repo.spongepowered.org/repository/maven-public/</url>
-        </repository>
-        <repository>
-            <id>bungeecord-repo</id>
-            <url>https://oss.sonatype.org/content/repositories/snapshots</url>
-        </repository>
-        <repository>
-            <id>velocity-repo</id>
-            <url>https://repo.velocitypowered.com/snapshots/</url>
-        </repository>
-    </repositories>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>ap</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>provided</scope>
-        </dependency>
-    </dependencies>
-
-    <modules>
-        <module>bungeecord</module>
-        <module>spigot</module>
-        <module>sponge</module>
-        <module>standalone</module>
-        <module>velocity</module>
-    </modules>
-</project>
\ No newline at end of file
diff --git a/bootstrap/spigot/build.gradle.kts b/bootstrap/spigot/build.gradle.kts
new file mode 100644
index 000000000..5a459a09b
--- /dev/null
+++ b/bootstrap/spigot/build.gradle.kts
@@ -0,0 +1,68 @@
+val paperVersion = "1.19-R0.1-SNAPSHOT"
+val viaVersion = "4.0.0"
+val adaptersVersion = "1.5-SNAPSHOT"
+val commodoreVersion = "2.2"
+
+dependencies {
+    api(projects.core)
+
+    implementation("org.geysermc.geyser.adapters", "spigot-all", adaptersVersion)
+
+    implementation("me.lucko", "commodore", commodoreVersion)
+
+    implementation("net.kyori", "adventure-text-serializer-bungeecord", Versions.adventurePlatformVersion)
+    
+    // Both paper-api and paper-mojangapi only provide Java 17 versions for 1.19
+    compileOnly("io.papermc.paper", "paper-api", paperVersion) {
+        attributes {
+            attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
+        }
+    }
+    compileOnly("io.papermc.paper", "paper-mojangapi", paperVersion) {
+        attributes {
+            attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
+        }
+    }
+}
+
+platformRelocate("it.unimi.dsi.fastutil")
+platformRelocate("com.fasterxml.jackson")
+// Relocate net.kyori but exclude the component logger
+platformRelocate("net.kyori", "net.kyori.adventure.text.logger.slf4j.ComponentLogger")
+platformRelocate("org.objectweb.asm")
+platformRelocate("me.lucko.commodore")
+platformRelocate("io.netty.channel.kqueue")
+
+// These dependencies are already present on the platform
+provided("com.viaversion", "viaversion", viaVersion)
+
+application {
+    mainClass.set("org.geysermc.geyser.platform.spigot.GeyserSpigotMain")
+}
+
+tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
+    archiveBaseName.set("Geyser-Spigot")
+
+    dependencies {
+        exclude(dependency("com.google.*:.*"))
+        exclude(dependency("org.yaml:.*"))
+
+        // We cannot shade Netty, or else native libraries will not load
+        // Needed because older Spigot builds do not provide the haproxy module
+        exclude(dependency("io.netty:netty-transport-native-epoll:.*"))
+        exclude(dependency("io.netty:netty-transport-native-unix-common:.*"))
+        exclude(dependency("io.netty:netty-transport-native-kqueue:.*"))
+        exclude(dependency("io.netty:netty-handler:.*"))
+        exclude(dependency("io.netty:netty-common:.*"))
+        exclude(dependency("io.netty:netty-buffer:.*"))
+        exclude(dependency("io.netty:netty-resolver:.*"))
+        exclude(dependency("io.netty:netty-transport:.*"))
+        exclude(dependency("io.netty:netty-codec:.*"))
+        exclude(dependency("io.netty:netty-codec-dns:.*"))
+        exclude(dependency("io.netty:netty-resolver-dns:.*"))
+        exclude(dependency("io.netty:netty-resolver-dns-native-macos:.*"))
+
+        // Commodore includes Brigadier
+        exclude(dependency("com.mojang:.*"))
+    }
+}
\ No newline at end of file
diff --git a/bootstrap/spigot/pom.xml b/bootstrap/spigot/pom.xml
deleted file mode 100644
index ad4b58fe2..000000000
--- a/bootstrap/spigot/pom.xml
+++ /dev/null
@@ -1,163 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>bootstrap-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>bootstrap-spigot</artifactId>
-
-    <repositories>
-        <repository>
-            <id>papermc</id>
-            <url>https://repo.papermc.io/repository/maven-public/</url>
-        </repository>
-        <repository>
-            <id>viaversion-repo</id>
-            <url>https://repo.viaversion.com</url>
-        </repository>
-        <repository>
-            <!-- For Commodore -->
-            <id>minecraft-repo</id>
-            <url>https://libraries.minecraft.net/</url>
-        </repository>
-    </repositories>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>core</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>io.papermc.paper</groupId>
-            <artifactId>paper-api</artifactId>
-            <version>1.19-R0.1-SNAPSHOT</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>io.papermc.paper</groupId>
-            <artifactId>paper-mojangapi</artifactId>
-            <version>1.19-R0.1-SNAPSHOT</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.viaversion</groupId>
-            <artifactId>viaversion</artifactId>
-            <version>4.0.0</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.geysermc.geyser.adapters</groupId>
-            <artifactId>spigot-all</artifactId>
-            <version>1.5-SNAPSHOT</version>
-        </dependency>
-        <dependency>
-            <groupId>me.lucko</groupId>
-            <artifactId>commodore</artifactId>
-            <version>2.2</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.kyori</groupId>
-            <artifactId>adventure-text-serializer-bungeecord</artifactId>
-            <version>${adventure-platform.version}</version>
-            <scope>compile</scope>
-        </dependency>
-    </dependencies>
-    <build>
-        <finalName>${outputName}-Spigot</finalName>
-        <resources>
-            <resource>
-                <directory>src/main/resources/</directory>
-                <filtering>true</filtering>
-            </resource>
-        </resources>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-                <version>3.2.0</version>
-                <configuration>
-                    <archive>
-                        <manifestEntries>
-                            <Main-Class>org.geysermc.geyser.platform.spigot.GeyserSpigotMain</Main-Class>
-                        </manifestEntries>
-                    </archive>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-shade-plugin</artifactId>
-                <version>3.3.0</version>
-                <executions>
-                    <execution>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>shade</goal>
-                        </goals>
-                        <configuration>
-                            <relocations>
-                                <relocation>
-                                    <pattern>it.unimi.dsi.fastutil</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.fastutil</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>com.fasterxml.jackson</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.jackson</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>net.kyori</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.kyori</shadedPattern>
-                                    <excludes>
-                                        <exclude>net.kyori.adventure.text.logger.slf4j.ComponentLogger</exclude>
-                                    </excludes>
-                                </relocation>
-                                <relocation>
-                                    <pattern>org.objectweb.asm</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.asm</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>me.lucko.commodore</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.commodore</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <!-- This is not used because relocating breaks natives, but we must include it
-                                     or else we get ClassDefNotFound after 1.18.2 -->
-                                    <pattern>io.netty.channel.kqueue</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.io.netty.channel.kqueue</shadedPattern>
-                                </relocation>
-                            </relocations>
-                        </configuration>
-                    </execution>
-                </executions>
-                <configuration>
-                    <artifactSet>
-                        <excludes>
-                            <exclude>com.google.*:*</exclude>
-                            <exclude>org.yaml:*</exclude>
-                            <!-- We cannot shade Netty, or else native libraries will not load -->
-                            <!-- Needed because older Spigot builds do not provide the haproxy module -->
-                            <exclude>io.netty:netty-transport-native-epoll:*</exclude>
-                            <exclude>io.netty:netty-transport-native-unix-common:*</exclude>
-                            <exclude>io.netty:netty-handler:*</exclude>
-                            <exclude>io.netty:netty-common:*</exclude>
-                            <exclude>io.netty:netty-buffer:*</exclude>
-                            <exclude>io.netty:netty-resolver:*</exclude>
-                            <exclude>io.netty:netty-transport:*</exclude>
-                            <exclude>io.netty:netty-codec:*</exclude>
-                            <exclude>io.netty:netty-codec-dns:*</exclude>
-                            <exclude>io.netty:netty-resolver-dns:*</exclude>
-                            <exclude>io.netty:netty-resolver-dns-native-macos:*</exclude>
-                            <exclude>com.mojang:*</exclude> <!-- Commodore includes Brigadier -->
-                        </excludes>
-                    </artifactSet>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-</project>
\ No newline at end of file
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperPingPassthrough.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperPingPassthrough.java
index 15bd6bde1..36dd81d44 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperPingPassthrough.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperPingPassthrough.java
@@ -29,7 +29,7 @@ import com.destroystokyo.paper.event.server.PaperServerListPingEvent;
 import com.destroystokyo.paper.network.StatusClient;
 import com.destroystokyo.paper.profile.PlayerProfile;
 import org.bukkit.Bukkit;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.ping.GeyserPingInfo;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
 import org.jetbrains.annotations.NotNull;
@@ -62,11 +62,11 @@ public final class GeyserPaperPingPassthrough implements IGeyserPingPassthrough
                 // Approximately pre-1.19
                 event = OLD_CONSTRUCTOR.newInstance(new GeyserStatusClient(inetSocketAddress),
                         Bukkit.getMotd(), Bukkit.getOnlinePlayers().size(),
-                        Bukkit.getMaxPlayers(), Bukkit.getVersion(), MinecraftProtocol.getJavaProtocolVersion(), null);
+                        Bukkit.getMaxPlayers(), Bukkit.getVersion(), GameProtocol.getJavaProtocolVersion(), null);
             } else {
                 event = new PaperServerListPingEvent(new GeyserStatusClient(inetSocketAddress),
                         Bukkit.getMotd(), Bukkit.shouldSendChatPreviews(), Bukkit.getOnlinePlayers().size(),
-                        Bukkit.getMaxPlayers(), Bukkit.getVersion(), MinecraftProtocol.getJavaProtocolVersion(), null);
+                        Bukkit.getMaxPlayers(), Bukkit.getVersion(), GameProtocol.getJavaProtocolVersion(), null);
             }
             Bukkit.getPluginManager().callEvent(event);
             if (event.isCancelled()) {
@@ -82,7 +82,7 @@ public final class GeyserPaperPingPassthrough implements IGeyserPingPassthrough
             }
 
             GeyserPingInfo geyserPingInfo = new GeyserPingInfo(event.getMotd(), players,
-                    new GeyserPingInfo.Version(Bukkit.getVersion(), MinecraftProtocol.getJavaProtocolVersion()));
+                    new GeyserPingInfo.Version(Bukkit.getVersion(), GameProtocol.getJavaProtocolVersion()));
 
             if (!event.shouldHidePlayers()) {
                 for (PlayerProfile profile : event.getPlayerSample()) {
@@ -105,7 +105,7 @@ public final class GeyserPaperPingPassthrough implements IGeyserPingPassthrough
 
         @Override
         public int getProtocolVersion() {
-            return MinecraftProtocol.getJavaProtocolVersion();
+            return GameProtocol.getJavaProtocolVersion();
         }
 
         @Override
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotDumpInfo.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotDumpInfo.java
index 7f8213155..8055a375f 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotDumpInfo.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotDumpInfo.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.platform.spigot;
 import lombok.Getter;
 import org.bukkit.Bukkit;
 import org.bukkit.plugin.Plugin;
-import org.geysermc.geyser.text.AsteriskSerializer;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
+import org.geysermc.geyser.text.AsteriskSerializer;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java
index 0fd8d849b..c1d3b6871 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java
@@ -170,8 +170,8 @@ public class GeyserSpigotInjector extends GeyserInjector {
      */
     private void workAroundWeirdBug(GeyserBootstrap bootstrap) {
         MinecraftProtocol protocol = new MinecraftProtocol();
-        LocalSession session = new LocalSession(bootstrap.getGeyserConfig().getRemote().getAddress(),
-                bootstrap.getGeyserConfig().getRemote().getPort(), this.serverSocketAddress,
+        LocalSession session = new LocalSession(bootstrap.getGeyserConfig().getRemote().address(),
+                bootstrap.getGeyserConfig().getRemote().port(), this.serverSocketAddress,
                 InetAddress.getLoopbackAddress().getHostAddress(), protocol, protocol.createHelper());
         session.connect();
     }
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPingPassthrough.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPingPassthrough.java
index db5a0a1e1..634d1f8a8 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPingPassthrough.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPingPassthrough.java
@@ -30,7 +30,7 @@ import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
 import org.bukkit.event.server.ServerListPingEvent;
 import org.bukkit.util.CachedServerIcon;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.ping.GeyserPingInfo;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
 
@@ -52,7 +52,7 @@ public class GeyserSpigotPingPassthrough implements IGeyserPingPassthrough {
             Bukkit.getPluginManager().callEvent(event);
             GeyserPingInfo geyserPingInfo = new GeyserPingInfo(event.getMotd(),
                     new GeyserPingInfo.Players(event.getMaxPlayers(), event.getNumPlayers()),
-                    new GeyserPingInfo.Version(Bukkit.getVersion(), MinecraftProtocol.getJavaProtocolVersion()) // thanks Spigot for not exposing this, just default to latest
+                    new GeyserPingInfo.Version(Bukkit.getVersion(), GameProtocol.getJavaProtocolVersion()) // thanks Spigot for not exposing this, just default to latest
             );
             Bukkit.getOnlinePlayers().stream().map(Player::getName).forEach(geyserPingInfo.getPlayerList()::add);
             return geyserPingInfo;
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java
index a1d9245e8..60b1cfa21 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java
@@ -32,36 +32,44 @@ import com.viaversion.viaversion.api.protocol.version.ProtocolVersion;
 import io.netty.buffer.ByteBuf;
 import me.lucko.commodore.CommodoreProvider;
 import org.bukkit.Bukkit;
+import org.bukkit.command.CommandMap;
 import org.bukkit.command.PluginCommand;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.server.ServerLoadEvent;
 import org.bukkit.permissions.Permission;
 import org.bukkit.permissions.PermissionDefault;
+import org.bukkit.plugin.Plugin;
 import org.bukkit.plugin.java.JavaPlugin;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserBootstrap;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.adapters.spigot.SpigotAdapters;
-import org.geysermc.geyser.command.CommandManager;
-import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.network.AuthType;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
 import org.geysermc.geyser.level.WorldManager;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.ping.GeyserLegacyPingPassthrough;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
 import org.geysermc.geyser.platform.spigot.command.GeyserBrigadierSupport;
 import org.geysermc.geyser.platform.spigot.command.GeyserSpigotCommandExecutor;
 import org.geysermc.geyser.platform.spigot.command.GeyserSpigotCommandManager;
-import org.geysermc.geyser.platform.spigot.command.SpigotCommandSender;
+import org.geysermc.geyser.platform.spigot.command.SpigotCommandSource;
 import org.geysermc.geyser.platform.spigot.world.GeyserPistonListener;
 import org.geysermc.geyser.platform.spigot.world.GeyserSpigotBlockPlaceListener;
 import org.geysermc.geyser.platform.spigot.world.manager.*;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.FileUtils;
 
 import java.io.File;
 import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
 import java.net.SocketAddress;
 import java.nio.file.Path;
 import java.util.List;
@@ -90,24 +98,7 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
     private String minecraftVersion;
 
     @Override
-    public void onEnable() {
-        GeyserLocale.init(this);
-
-        // This is manually done instead of using Bukkit methods to save the config because otherwise comments get removed
-        try {
-            if (!getDataFolder().exists()) {
-                getDataFolder().mkdir();
-            }
-            File configFile = FileUtils.fileOrCopiedFromResource(new File(getDataFolder(), "config.yml"), "config.yml",
-                    (x) -> x.replaceAll("generateduuid", UUID.randomUUID().toString()), this);
-            this.geyserConfig = FileUtils.loadConfig(configFile, GeyserSpigotConfiguration.class);
-        } catch (IOException ex) {
-            getLogger().log(Level.SEVERE, GeyserLocale.getLocaleStringLog("geyser.config.failed"), ex);
-            ex.printStackTrace();
-            Bukkit.getPluginManager().disablePlugin(this);
-            return;
-        }
-
+    public void onLoad() {
         try {
             // AvailableCommandsSerializer_v291 complains otherwise
             ByteBuf.class.getMethod("writeShortLE", int.class);
@@ -139,8 +130,51 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             }
         }
 
+        GeyserLocale.init(this);
+
+        // This is manually done instead of using Bukkit methods to save the config because otherwise comments get removed
+        try {
+            if (!getDataFolder().exists()) {
+                getDataFolder().mkdir();
+            }
+            File configFile = FileUtils.fileOrCopiedFromResource(new File(getDataFolder(), "config.yml"), "config.yml",
+                    (x) -> x.replaceAll("generateduuid", UUID.randomUUID().toString()), this);
+            this.geyserConfig = FileUtils.loadConfig(configFile, GeyserSpigotConfiguration.class);
+        } catch (IOException ex) {
+            getLogger().log(Level.SEVERE, GeyserLocale.getLocaleStringLog("geyser.config.failed"), ex);
+            ex.printStackTrace();
+            Bukkit.getPluginManager().disablePlugin(this);
+            return;
+        }
+
+        this.geyserLogger = GeyserPaperLogger.supported() ? new GeyserPaperLogger(this, getLogger(), geyserConfig.isDebugMode())
+                : new GeyserSpigotLogger(getLogger(), geyserConfig.isDebugMode());
+
+        GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
+
+        this.geyser = GeyserImpl.load(PlatformType.SPIGOT, this);
+    }
+
+    @Override
+    public void onEnable() {
+        // Remove this in like a year
+        if (Bukkit.getPluginManager().getPlugin("floodgate-bukkit") != null) {
+            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.outdated", Constants.FLOODGATE_DOWNLOAD_LOCATION));
+            this.getPluginLoader().disablePlugin(this);
+            return;
+        }
+
+        if (geyserConfig.getRemote().authType() == AuthType.FLOODGATE && Bukkit.getPluginManager().getPlugin("floodgate") == null) {
+            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.not_installed") + " " + GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.disabling"));
+            this.getPluginLoader().disablePlugin(this);
+        } else if (geyserConfig.isAutoconfiguredRemote() && Bukkit.getPluginManager().getPlugin("floodgate") != null) {
+            // Floodgate installed means that the user wants Floodgate authentication
+            geyserLogger.debug("Auto-setting to Floodgate authentication.");
+            geyserConfig.getRemote().setAuthType(AuthType.FLOODGATE);
+        }
+
         // By default this should be localhost but may need to be changed in some circumstances
-        if (this.geyserConfig.getRemote().getAddress().equalsIgnoreCase("auto")) {
+        if (this.geyserConfig.getRemote().address().equalsIgnoreCase("auto")) {
             geyserConfig.setAutoconfiguredRemote(true);
             // Don't use localhost if not listening on all interfaces
             if (!Bukkit.getIp().equals("0.0.0.0") && !Bukkit.getIp().equals("")) {
@@ -153,34 +187,47 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             geyserConfig.getBedrock().setPort(Bukkit.getPort());
         }
 
-        this.geyserLogger = GeyserPaperLogger.supported() ? new GeyserPaperLogger(this, getLogger(), geyserConfig.isDebugMode())
-                : new GeyserSpigotLogger(getLogger(), geyserConfig.isDebugMode());
-        GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
-
-        // Remove this in like a year
-        if (Bukkit.getPluginManager().getPlugin("floodgate-bukkit") != null) {
-            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.outdated", Constants.FLOODGATE_DOWNLOAD_LOCATION));
-            this.getPluginLoader().disablePlugin(this);
-            return;
-        }
-
-        if (geyserConfig.getRemote().getAuthType() == AuthType.FLOODGATE && Bukkit.getPluginManager().getPlugin("floodgate") == null) {
-            geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.not_installed") + " " + GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.disabling"));
-            this.getPluginLoader().disablePlugin(this);
-            return;
-        } else if (geyserConfig.isAutoconfiguredRemote() && Bukkit.getPluginManager().getPlugin("floodgate") != null) {
-            // Floodgate installed means that the user wants Floodgate authentication
-            geyserLogger.debug("Auto-setting to Floodgate authentication.");
-            geyserConfig.getRemote().setAuthType(AuthType.FLOODGATE);
-        }
-
         geyserConfig.loadFloodgate(this);
 
+        // Needs to be an anonymous inner class otherwise Bukkit complains about missing classes
+        Bukkit.getPluginManager().registerEvents(new Listener() {
+
+            @EventHandler
+            public void onServerLoaded(ServerLoadEvent event) {
+                // Wait until all plugins have loaded so Geyser can start
+                postStartup();
+            }
+        }, this);
+
+        this.geyserCommandManager = new GeyserSpigotCommandManager(geyser);
+        this.geyserCommandManager.init();
+
+        // Because Bukkit locks its command map upon startup, we need to
+        // add our plugin commands in onEnable, but populating the executor
+        // can happen at any time
+        CommandMap commandMap = GeyserSpigotCommandManager.getCommandMap();
+        for (Extension extension : this.geyserCommandManager.extensionCommands().keySet()) {
+            // Thanks again, Bukkit
+            try {
+                Constructor<PluginCommand> constructor = PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
+                constructor.setAccessible(true);
+
+                PluginCommand pluginCommand = constructor.newInstance(extension.description().id(), this);
+                pluginCommand.setDescription("The main command for the " + extension.name() + " Geyser extension!");
+
+                commandMap.register(extension.description().id(), "geyserext", pluginCommand);
+            } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException ex) {
+                this.geyserLogger.error("Failed to construct PluginCommand for extension " + extension.description().name(), ex);
+            }
+        }
+    }
+
+    private void postStartup() {
+        GeyserImpl.start();
+
         // Turn "(MC: 1.16.4)" into 1.16.4.
         this.minecraftVersion = Bukkit.getServer().getVersion().split("\\(MC: ")[1].split("\\)")[0];
 
-        this.geyser = GeyserImpl.start(PlatformType.SPIGOT, this);
-
         if (geyserConfig.isLegacyPingPassthrough()) {
             this.geyserSpigotPingPassthrough = GeyserLegacyPingPassthrough.init(geyser);
         } else {
@@ -195,8 +242,6 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
         }
         geyserLogger.debug("Spigot ping passthrough type: " + (this.geyserSpigotPingPassthrough == null ? null : this.geyserSpigotPingPassthrough.getClass()));
 
-        this.geyserCommandManager = new GeyserSpigotCommandManager(geyser);
-
         boolean isViaVersion = Bukkit.getPluginManager().getPlugin("ViaVersion") != null;
         if (isViaVersion) {
             try {
@@ -217,7 +262,7 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
 
         boolean isPre1_12 = !isCompatible(Bukkit.getServer().getVersion(), "1.12.0");
         // Set if we need to use a different method for getting a player's locale
-        SpigotCommandSender.setUseLegacyLocaleMethod(isPre1_12);
+        SpigotCommandSource.setUseLegacyLocaleMethod(isPre1_12);
 
         // We want to do this late in the server startup process to allow plugins such as ViaVersion and ProtocolLib
         // To do their job injecting, then connect into *that*
@@ -266,23 +311,57 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             geyserLogger.debug("Using default world manager: " + this.geyserWorldManager.getClass());
         }
 
-        PluginCommand pluginCommand = this.getCommand("geyser");
-        pluginCommand.setExecutor(new GeyserSpigotCommandExecutor(geyser));
+        PluginCommand geyserCommand = this.getCommand("geyser");
+        geyserCommand.setExecutor(new GeyserSpigotCommandExecutor(geyser, geyserCommandManager.getCommands()));
+
+        for (Map.Entry<Extension, Map<String, Command>> entry : this.geyserCommandManager.extensionCommands().entrySet()) {
+            Map<String, Command> commands = entry.getValue();
+            if (commands.isEmpty()) {
+                continue;
+            }
+
+            PluginCommand command = this.getCommand(entry.getKey().description().id());
+            if (command == null) {
+                continue;
+            }
+
+            command.setExecutor(new GeyserSpigotCommandExecutor(this.geyser, commands));
+        }
 
         if (!INITIALIZED) {
             // Register permissions so they appear in, for example, LuckPerms' UI
             // Re-registering permissions throws an error
-            for (Map.Entry<String, GeyserCommand> entry : geyserCommandManager.getCommands().entrySet()) {
-                GeyserCommand command = entry.getValue();
-                if (command.getAliases().contains(entry.getKey())) {
+            for (Map.Entry<String, Command> entry : geyserCommandManager.commands().entrySet()) {
+                Command command = entry.getValue();
+                if (command.aliases().contains(entry.getKey())) {
                     // Don't register aliases
                     continue;
                 }
 
-                Bukkit.getPluginManager().addPermission(new Permission(command.getPermission(),
-                        GeyserLocale.getLocaleStringLog(command.getDescription()),
+                Bukkit.getPluginManager().addPermission(new Permission(command.permission(),
+                        GeyserLocale.getLocaleStringLog(command.description()),
                         command.isSuggestedOpOnly() ? PermissionDefault.OP : PermissionDefault.TRUE));
             }
+
+            // Register permissions for extension commands
+            for (Map.Entry<Extension, Map<String, Command>> commandEntry : this.geyserCommandManager.extensionCommands().entrySet()) {
+                for (Map.Entry<String, Command> entry : commandEntry.getValue().entrySet()) {
+                    Command command = entry.getValue();
+                    if (command.aliases().contains(entry.getKey())) {
+                        // Don't register aliases
+                        continue;
+                    }
+
+                    if (command.permission().isBlank()) {
+                        continue;
+                    }
+
+                    Bukkit.getPluginManager().addPermission(new Permission(command.permission(),
+                            GeyserLocale.getLocaleStringLog(command.description()),
+                            command.isSuggestedOpOnly() ? PermissionDefault.OP : PermissionDefault.TRUE));
+                }
+            }
+
             Bukkit.getPluginManager().addPermission(new Permission(Constants.UPDATE_PERMISSION,
                     "Whether update notifications can be seen", PermissionDefault.OP));
 
@@ -298,7 +377,7 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
         boolean brigadierSupported = CommodoreProvider.isSupported();
         geyserLogger.debug("Brigadier supported? " + brigadierSupported);
         if (brigadierSupported) {
-            GeyserBrigadierSupport.loadBrigadier(this, pluginCommand);
+            GeyserBrigadierSupport.loadBrigadier(this, geyserCommand);
         }
 
         // Check to ensure the current setup can support the protocol version Geyser uses
@@ -328,7 +407,7 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
     }
 
     @Override
-    public CommandManager getGeyserCommandManager() {
+    public GeyserCommandManager getGeyserCommandManager() {
         return this.geyserCommandManager;
     }
 
@@ -410,7 +489,7 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
      */
     private boolean isViaVersionNeeded() {
         ProtocolVersion serverVersion = getServerProtocolVersion();
-        List<ProtocolPathEntry> protocolList = Via.getManager().getProtocolManager().getProtocolPath(MinecraftProtocol.getJavaProtocolVersion(),
+        List<ProtocolPathEntry> protocolList = Via.getManager().getProtocolManager().getProtocolPath(GameProtocol.getJavaProtocolVersion(),
                 serverVersion.getVersion());
         if (protocolList == null) {
             // No translation needed!
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java
index 02f5367b3..5e3c4def8 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java
@@ -31,7 +31,7 @@ import org.bukkit.event.Listener;
 import org.bukkit.event.player.PlayerJoinEvent;
 import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.platform.spigot.command.SpigotCommandSender;
+import org.geysermc.geyser.platform.spigot.command.SpigotCommandSource;
 import org.geysermc.geyser.util.VersionCheckUtils;
 
 public final class GeyserSpigotUpdateListener implements Listener {
@@ -41,7 +41,7 @@ public final class GeyserSpigotUpdateListener implements Listener {
         if (GeyserImpl.getInstance().getConfig().isNotifyOnNewBedrockUpdate()) {
             final Player player = event.getPlayer();
             if (player.hasPermission(Constants.UPDATE_PERMISSION)) {
-                VersionCheckUtils.checkForGeyserUpdate(() -> new SpigotCommandSender(player));
+                VersionCheckUtils.checkForGeyserUpdate(() -> new SpigotCommandSource(player));
             }
         }
     }
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotVersionChecker.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotVersionChecker.java
index 923209e59..0212ff9b0 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotVersionChecker.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotVersionChecker.java
@@ -29,7 +29,7 @@ import com.viaversion.viaversion.api.Via;
 import org.bukkit.Bukkit;
 import org.bukkit.UnsafeValues;
 import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.lang.reflect.InvocationTargetException;
@@ -48,7 +48,7 @@ public final class GeyserSpigotVersionChecker {
         try {
             // This method is only present on later versions of Paper
             UnsafeValues.class.getMethod("getProtocolVersion");
-            if (Bukkit.getUnsafe().getProtocolVersion() != MinecraftProtocol.getJavaProtocolVersion()) {
+            if (Bukkit.getUnsafe().getProtocolVersion() != GameProtocol.getJavaProtocolVersion()) {
                 sendOutdatedMessage(logger);
             }
             return;
@@ -82,7 +82,7 @@ public final class GeyserSpigotVersionChecker {
                     }
                     return;
                 }
-                if (protocolVersion != MinecraftProtocol.getJavaProtocolVersion()) {
+                if (protocolVersion != GameProtocol.getJavaProtocolVersion()) {
                     sendOutdatedMessage(logger);
                 }
                 return;
@@ -94,13 +94,13 @@ public final class GeyserSpigotVersionChecker {
     private static void checkViaVersionSupportedVersions(GeyserLogger logger) {
         // Run after ViaVersion has obtained the server protocol version
         Via.getPlatform().runSync(() -> {
-            if (Via.getAPI().getSupportedVersions().contains(MinecraftProtocol.getJavaProtocolVersion())) {
+            if (Via.getAPI().getSupportedVersions().contains(GameProtocol.getJavaProtocolVersion())) {
                 // Via supports this protocol version; we will be able to connect.
                 return;
             }
-            if (Via.getAPI().getFullSupportedVersions().contains(MinecraftProtocol.getJavaProtocolVersion())) {
+            if (Via.getAPI().getFullSupportedVersions().contains(GameProtocol.getJavaProtocolVersion())) {
                 // ViaVersion supports our protocol, but the user has blocked them from connecting.
-                logger.warning(GeyserLocale.getLocaleStringLog("geyser.bootstrap.viaversion.blocked", MinecraftProtocol.getAllSupportedJavaVersions()));
+                logger.warning(GeyserLocale.getLocaleStringLog("geyser.bootstrap.viaversion.blocked", GameProtocol.getAllSupportedJavaVersions()));
                 return;
             }
             // Else, presumably, ViaVersion is not updated.
@@ -114,7 +114,7 @@ public final class GeyserSpigotVersionChecker {
     }
 
     private static void sendOutdatedMessage(GeyserLogger logger) {
-        logger.warning(GeyserLocale.getLocaleStringLog("geyser.bootstrap.no_supported_protocol", MinecraftProtocol.getAllSupportedJavaVersions(), VIAVERSION_DOWNLOAD_URL));
+        logger.warning(GeyserLocale.getLocaleStringLog("geyser.bootstrap.no_supported_protocol", GameProtocol.getAllSupportedJavaVersions(), VIAVERSION_DOWNLOAD_URL));
     }
 
     private GeyserSpigotVersionChecker() {
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserPaperCommandListener.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserPaperCommandListener.java
index 00c1ba58d..9375e3a62 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserPaperCommandListener.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserPaperCommandListener.java
@@ -31,7 +31,7 @@ import org.bukkit.entity.Player;
 import org.bukkit.event.EventHandler;
 import org.bukkit.event.Listener;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.api.command.Command;
 
 import java.net.InetSocketAddress;
 import java.util.Iterator;
@@ -49,14 +49,14 @@ public final class GeyserPaperCommandListener implements Listener {
             if (geyserBrigadier != null) {
                 Player player = event.getPlayer();
                 boolean isJavaPlayer = isProbablyJavaPlayer(player);
-                Map<String, GeyserCommand> commands = GeyserImpl.getInstance().getCommandManager().getCommands();
+                Map<String, Command> commands = GeyserImpl.getInstance().commandManager().getCommands();
                 Iterator<? extends CommandNode<?>> it = geyserBrigadier.getChildren().iterator();
 
                 while (it.hasNext()) {
                     CommandNode<?> subnode = it.next();
-                    GeyserCommand command = commands.get(subnode.getName());
+                    Command command = commands.get(subnode.getName());
                     if (command != null) {
-                        if ((command.isBedrockOnly() && isJavaPlayer) || !player.hasPermission(command.getPermission())) {
+                        if ((command.isBedrockOnly() && isJavaPlayer) || !player.hasPermission(command.permission())) {
                             // Remove this from the node as we don't have permission to use it
                             it.remove();
                         }
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandExecutor.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandExecutor.java
index b1bcfcaf8..52779db23 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandExecutor.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandExecutor.java
@@ -30,37 +30,38 @@ import org.bukkit.command.Command;
 import org.bukkit.command.CommandSender;
 import org.bukkit.command.TabExecutor;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandExecutor;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandExecutor;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
-public class GeyserSpigotCommandExecutor extends CommandExecutor implements TabExecutor {
+public class GeyserSpigotCommandExecutor extends GeyserCommandExecutor implements TabExecutor {
 
-    public GeyserSpigotCommandExecutor(GeyserImpl geyser) {
-        super(geyser);
+    public GeyserSpigotCommandExecutor(GeyserImpl geyser, Map<String, org.geysermc.geyser.api.command.Command> commands) {
+        super(geyser, commands);
     }
 
     @Override
     public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
-        SpigotCommandSender commandSender = new SpigotCommandSender(sender);
+        SpigotCommandSource commandSender = new SpigotCommandSource(sender);
         GeyserSession session = getGeyserSession(commandSender);
 
         if (args.length > 0) {
             GeyserCommand geyserCommand = getCommand(args[0]);
             if (geyserCommand != null) {
-                if (!sender.hasPermission(geyserCommand.getPermission())) {
-                    String message = GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", commandSender.getLocale());
+                if (!sender.hasPermission(geyserCommand.permission())) {
+                    String message = GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", commandSender.locale());
 
                     commandSender.sendMessage(ChatColor.RED + message);
                     return true;
                 }
                 if (geyserCommand.isBedrockOnly() && session == null) {
-                    sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.bedrock_only", commandSender.getLocale()));
+                    sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.bedrock_only", commandSender.locale()));
                     return true;
                 }
                 geyserCommand.execute(session, commandSender, args.length > 1 ? Arrays.copyOfRange(args, 1, args.length) : new String[0]);
@@ -76,7 +77,7 @@ public class GeyserSpigotCommandExecutor extends CommandExecutor implements TabE
     @Override
     public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
         if (args.length == 1) {
-            return tabComplete(new SpigotCommandSender(sender));
+            return tabComplete(new SpigotCommandSource(sender));
         }
         return Collections.emptyList();
     }
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandManager.java
index 6107d5b47..655d3be23 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/GeyserSpigotCommandManager.java
@@ -30,11 +30,11 @@ import org.bukkit.Server;
 import org.bukkit.command.Command;
 import org.bukkit.command.CommandMap;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.command.GeyserCommandManager;
 
 import java.lang.reflect.Field;
 
-public class GeyserSpigotCommandManager extends CommandManager {
+public class GeyserSpigotCommandManager extends GeyserCommandManager {
 
     private static final CommandMap COMMAND_MAP;
 
@@ -61,8 +61,12 @@ public class GeyserSpigotCommandManager extends CommandManager {
     }
 
     @Override
-    public String getDescription(String command) {
+    public String description(String command) {
         Command cmd = COMMAND_MAP.getCommand(command.replace("/", ""));
         return cmd != null ? cmd.getDescription() : "";
     }
+
+    public static CommandMap getCommandMap() {
+        return COMMAND_MAP;
+    }
 }
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSource.java
similarity index 95%
rename from bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java
rename to bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSource.java
index cef92f744..8deddd8e6 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSource.java
@@ -30,14 +30,14 @@ import net.kyori.adventure.text.serializer.bungeecord.BungeeComponentSerializer;
 import org.bukkit.command.ConsoleCommandSender;
 import org.bukkit.entity.Player;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.platform.spigot.PaperAdventure;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 
-public class SpigotCommandSender implements CommandSender {
+public class SpigotCommandSource implements GeyserCommandSource {
 
     /**
      * Whether to use {@code Player.getLocale()} or {@code Player.spigot().getLocale()}, depending on version.
@@ -49,7 +49,7 @@ public class SpigotCommandSender implements CommandSender {
     private final org.bukkit.command.CommandSender handle;
     private final String locale;
 
-    public SpigotCommandSender(org.bukkit.command.CommandSender handle) {
+    public SpigotCommandSource(org.bukkit.command.CommandSender handle) {
         this.handle = handle;
         this.locale = getSpigotLocale();
         // Ensure even Java players' languages are loaded
@@ -83,7 +83,7 @@ public class SpigotCommandSender implements CommandSender {
     }
 
     @Override
-    public String getLocale() {
+    public String locale() {
         return locale;
     }
 
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserPistonListener.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserPistonListener.java
index 981d00b97..8be1cb84e 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserPistonListener.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserPistonListener.java
@@ -41,12 +41,12 @@ import org.bukkit.event.block.BlockPistonEvent;
 import org.bukkit.event.block.BlockPistonExtendEvent;
 import org.bukkit.event.block.BlockPistonRetractEvent;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.session.cache.PistonCache;
 import org.geysermc.geyser.level.block.BlockStateValues;
-import org.geysermc.geyser.translator.level.block.entity.PistonBlockEntity;
 import org.geysermc.geyser.level.physics.Direction;
 import org.geysermc.geyser.platform.spigot.world.manager.GeyserSpigotWorldManager;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.session.cache.PistonCache;
+import org.geysermc.geyser.translator.level.block.entity.PistonBlockEntity;
 
 import java.util.List;
 import java.util.Map;
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserSpigotBlockPlaceListener.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserSpigotBlockPlaceListener.java
index 62a56bd2d..d486501de 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserSpigotBlockPlaceListener.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/GeyserSpigotBlockPlaceListener.java
@@ -33,10 +33,10 @@ import org.bukkit.event.EventHandler;
 import org.bukkit.event.Listener;
 import org.bukkit.event.block.BlockPlaceEvent;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
-import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.platform.spigot.world.manager.GeyserSpigotWorldManager;
+import org.geysermc.geyser.registry.BlockRegistries;
+import org.geysermc.geyser.session.GeyserSession;
 
 @AllArgsConstructor
 public class GeyserSpigotBlockPlaceListener implements Listener {
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java
index 670070a68..0ac8d6856 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12NativeWorldManager.java
@@ -30,10 +30,10 @@ import com.viaversion.viaversion.protocols.protocol1_13to1_12_2.storage.BlockSto
 import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
 import org.bukkit.plugin.Plugin;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.adapters.spigot.SpigotAdapters;
 import org.geysermc.geyser.adapters.spigot.SpigotWorldAdapter;
+import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 /**
  * Used with ViaVersion and pre-1.13.
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java
index 1936d608f..2ca024abf 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigot1_12WorldManager.java
@@ -36,8 +36,8 @@ import org.bukkit.Bukkit;
 import org.bukkit.block.Block;
 import org.bukkit.entity.Player;
 import org.bukkit.plugin.Plugin;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.List;
 
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java
index 3079c523f..fa78a671c 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotFallbackWorldManager.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.platform.spigot.world.manager;
 
 import org.bukkit.plugin.Plugin;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 /**
  * Should only be used when we know {@link GeyserSpigotWorldManager#getBlockAt(GeyserSession, int, int, int)}
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java
index 2e0491db8..baffc9679 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotLegacyNativeWorldManager.java
@@ -32,9 +32,9 @@ import com.viaversion.viaversion.api.protocol.version.ProtocolVersion;
 import it.unimi.dsi.fastutil.ints.Int2IntMap;
 import it.unimi.dsi.fastutil.ints.Int2IntOpenHashMap;
 import it.unimi.dsi.fastutil.ints.IntList;
-import org.geysermc.geyser.network.MinecraftProtocol;
-import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.platform.spigot.GeyserSpigotPlugin;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.List;
 
@@ -50,7 +50,7 @@ public class GeyserSpigotLegacyNativeWorldManager extends GeyserSpigotNativeWorl
         IntList allBlockStates = adapter.getAllBlockStates();
         oldToNewBlockId = new Int2IntOpenHashMap(allBlockStates.size());
         ProtocolVersion serverVersion = plugin.getServerProtocolVersion();
-        List<ProtocolPathEntry> protocolList = Via.getManager().getProtocolManager().getProtocolPath(MinecraftProtocol.getJavaProtocolVersion(),
+        List<ProtocolPathEntry> protocolList = Via.getManager().getProtocolManager().getProtocolPath(GameProtocol.getJavaProtocolVersion(),
                 serverVersion.getVersion());
         for (int oldBlockId : allBlockStates) {
             int newBlockId = oldBlockId;
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java
index 2db01ab4f..bf9085979 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotNativeWorldManager.java
@@ -28,10 +28,10 @@ package org.geysermc.geyser.platform.spigot.world.manager;
 import org.bukkit.Bukkit;
 import org.bukkit.entity.Player;
 import org.bukkit.plugin.Plugin;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.adapters.spigot.SpigotAdapters;
 import org.geysermc.geyser.adapters.spigot.SpigotWorldAdapter;
+import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 public class GeyserSpigotNativeWorldManager extends GeyserSpigotWorldManager {
     protected final SpigotWorldAdapter adapter;
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java
index 0a6117b43..093e28794 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java
@@ -41,7 +41,7 @@ import org.bukkit.plugin.Plugin;
 import org.geysermc.geyser.level.GameRule;
 import org.geysermc.geyser.level.GeyserWorldManager;
 import org.geysermc.geyser.level.block.BlockStateValues;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.LecternInventoryTranslator;
@@ -57,7 +57,7 @@ public class GeyserSpigotWorldManager extends GeyserWorldManager {
     /**
      * The current client protocol version for ViaVersion usage.
      */
-    protected static final int CLIENT_PROTOCOL_VERSION = MinecraftProtocol.getJavaProtocolVersion();
+    protected static final int CLIENT_PROTOCOL_VERSION = GameProtocol.getJavaProtocolVersion();
 
     private final Plugin plugin;
 
diff --git a/bootstrap/spigot/src/main/resources/plugin.yml b/bootstrap/spigot/src/main/resources/plugin.yml
index aa2747979..e28b8981d 100644
--- a/bootstrap/spigot/src/main/resources/plugin.yml
+++ b/bootstrap/spigot/src/main/resources/plugin.yml
@@ -1,11 +1,11 @@
 main: org.geysermc.geyser.platform.spigot.GeyserSpigotPlugin
-name: ${outputName}-Spigot
-author: ${project.organization.name}
-website: ${project.organization.url}
-version: ${project.version}
+name: ${name}-Spigot
+author: ${author}
+website: ${url}
+version: ${version}
 softdepend: ["ViaVersion", "floodgate"]
 api-version: 1.13
 commands:
   geyser:
     description: The main command for Geyser.
-    usage: /geyser <subcommand>
+    usage: /geyser <subcommand>
\ No newline at end of file
diff --git a/bootstrap/sponge/build.gradle.kts b/bootstrap/sponge/build.gradle.kts
new file mode 100644
index 000000000..2850b2c5e
--- /dev/null
+++ b/bootstrap/sponge/build.gradle.kts
@@ -0,0 +1,36 @@
+val spongeVersion = "7.1.0"
+
+dependencies {
+    api(projects.core)
+}
+
+platformRelocate("com.fasterxml.jackson")
+platformRelocate("io.netty")
+platformRelocate("it.unimi.dsi.fastutil")
+platformRelocate("com.google.common")
+platformRelocate("com.google.guava")
+platformRelocate("net.kyori")
+
+// Exclude these dependencies
+exclude("com.google.code.gson:*")
+exclude("org.yaml:*")
+exclude("org.slf4j:*")
+exclude("org.ow2.asm:*")
+
+// These dependencies are already present on the platform
+provided("org.spongepowered", "spongeapi", spongeVersion)
+
+application {
+    mainClass.set("org.geysermc.geyser.platform.sponge.GeyserSpongeMain")
+}
+
+tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
+    archiveBaseName.set("Geyser-Sponge")
+
+    dependencies {
+        exclude(dependency("com.google.code.gson:.*"))
+        exclude(dependency("org.yaml:.*"))
+        exclude(dependency("org.slf4j:.*"))
+        exclude(dependency("org.ow2.asm:.*"))
+    }
+}
\ No newline at end of file
diff --git a/bootstrap/sponge/pom.xml b/bootstrap/sponge/pom.xml
deleted file mode 100644
index fc7bbc624..000000000
--- a/bootstrap/sponge/pom.xml
+++ /dev/null
@@ -1,101 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>bootstrap-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>bootstrap-sponge</artifactId>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>core</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.spongepowered</groupId>
-            <artifactId>spongeapi</artifactId>
-            <version>7.1.0</version>
-            <scope>provided</scope>
-        </dependency>
-    </dependencies>
-    <build>
-        <finalName>${outputName}-Sponge</finalName>
-        <resources>
-            <resource>
-                <directory>src/main/resources/</directory>
-                <filtering>true</filtering>
-            </resource>
-        </resources>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-                <version>3.2.0</version>
-                <configuration>
-                    <archive>
-                        <manifestEntries>
-                            <Main-Class>org.geysermc.geyser.platform.sponge.GeyserSpongeMain</Main-Class>
-                        </manifestEntries>
-                    </archive>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-shade-plugin</artifactId>
-                <version>3.3.0</version>
-                <executions>
-                    <execution>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>shade</goal>
-                        </goals>
-                        <configuration>
-                            <relocations>
-                                <relocation>
-                                    <pattern>com.fasterxml.jackson</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.sponge.shaded.jackson</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>io.netty</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.sponge.shaded.netty</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>it.unimi.dsi.fastutil</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.sponge.shaded.fastutil</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>com.google.common</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.sponge.shaded.google.common</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>com.google.guava</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.sponge.shaded.google.guava</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>net.kyori</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.sponge.shaded.kyori</shadedPattern>
-                                </relocation>
-                            </relocations>
-                        </configuration>
-                    </execution>
-                </executions>
-                <configuration>
-                    <artifactSet>
-                        <excludes>
-                            <exclude>com.google.code.gson:*</exclude>
-                            <exclude>org.yaml:*</exclude>
-                            <exclude>org.slf4j:*</exclude>
-                            <exclude>org.ow2.asm:*</exclude>
-                        </excludes>
-                    </artifactSet>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-</project>
\ No newline at end of file
diff --git a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePingPassthrough.java b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePingPassthrough.java
index 7c01f18ce..a661061e2 100644
--- a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePingPassthrough.java
+++ b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePingPassthrough.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser.platform.sponge;
 
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.ping.GeyserPingInfo;
-import org.geysermc.geyser.network.MinecraftProtocol;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
 import org.spongepowered.api.MinecraftVersion;
 import org.spongepowered.api.Sponge;
@@ -73,7 +73,7 @@ public class GeyserSpongePingPassthrough implements IGeyserPingPassthrough {
                 ),
                 new GeyserPingInfo.Version(
                         event.getResponse().getVersion().getName(),
-                        MinecraftProtocol.getJavaProtocolVersion()) // thanks for also not exposing this sponge
+                        GameProtocol.getJavaProtocolVersion()) // thanks for also not exposing this sponge
         );
         event.getResponse().getPlayers().get().getProfiles().stream()
                 .map(GameProfile::getName)
diff --git a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePlugin.java b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePlugin.java
index f5d6613c7..42040f6ab 100644
--- a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePlugin.java
+++ b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/GeyserSpongePlugin.java
@@ -27,17 +27,19 @@ package org.geysermc.geyser.platform.sponge;
 
 import com.google.inject.Inject;
 import org.geysermc.common.PlatformType;
-import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserBootstrap;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
 import org.geysermc.geyser.ping.GeyserLegacyPingPassthrough;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
-import org.geysermc.geyser.util.FileUtils;
-import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.platform.sponge.command.GeyserSpongeCommandExecutor;
 import org.geysermc.geyser.platform.sponge.command.GeyserSpongeCommandManager;
+import org.geysermc.geyser.text.GeyserLocale;
+import org.geysermc.geyser.util.FileUtils;
 import org.slf4j.Logger;
 import org.spongepowered.api.Sponge;
 import org.spongepowered.api.config.ConfigDir;
@@ -50,6 +52,7 @@ import java.io.File;
 import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.nio.file.Path;
+import java.util.Map;
 import java.util.UUID;
 
 @Plugin(id = "geyser", name = GeyserImpl.NAME + "-Sponge", version = GeyserImpl.VERSION, url = "https://geysermc.org", authors = "GeyserMC")
@@ -69,8 +72,7 @@ public class GeyserSpongePlugin implements GeyserBootstrap {
 
     private GeyserImpl geyser;
 
-    @Override
-    public void onEnable() {
+    public void onLoad() {
         GeyserLocale.init(this);
 
         if (!configDir.exists())
@@ -99,19 +101,25 @@ public class GeyserSpongePlugin implements GeyserBootstrap {
 
             // Don't change the ip if its listening on all interfaces
             // By default this should be 127.0.0.1 but may need to be changed in some circumstances
-            if (this.geyserConfig.getRemote().getAddress().equalsIgnoreCase("auto")) {
+            if (this.geyserConfig.getRemote().address().equalsIgnoreCase("auto")) {
                 this.geyserConfig.setAutoconfiguredRemote(true);
                 geyserConfig.getRemote().setPort(javaAddr.getPort());
             }
         }
 
         if (geyserConfig.getBedrock().isCloneRemotePort()) {
-            geyserConfig.getBedrock().setPort(geyserConfig.getRemote().getPort());
+            geyserConfig.getBedrock().setPort(geyserConfig.getRemote().port());
         }
 
         this.geyserLogger = new GeyserSpongeLogger(logger, geyserConfig.isDebugMode());
         GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
-        this.geyser = GeyserImpl.start(PlatformType.SPONGE, this);
+
+        this.geyser = GeyserImpl.load(PlatformType.SPONGE, this);
+    }
+
+    @Override
+    public void onEnable() {
+        GeyserImpl.start();
 
         if (geyserConfig.isLegacyPingPassthrough()) {
             this.geyserSpongePingPassthrough = GeyserLegacyPingPassthrough.init(geyser);
@@ -120,7 +128,18 @@ public class GeyserSpongePlugin implements GeyserBootstrap {
         }
 
         this.geyserCommandManager = new GeyserSpongeCommandManager(Sponge.getCommandManager(), geyser);
-        Sponge.getCommandManager().register(this, new GeyserSpongeCommandExecutor(geyser), "geyser");
+        this.geyserCommandManager.init();
+
+        Sponge.getCommandManager().register(this, new GeyserSpongeCommandExecutor(geyser, geyserCommandManager.getCommands()), "geyser");
+
+        for (Map.Entry<Extension, Map<String, Command>> entry : this.geyserCommandManager.extensionCommands().entrySet()) {
+            Map<String, Command> commands = entry.getValue();
+            if (commands.isEmpty()) {
+                continue;
+            }
+
+            Sponge.getCommandManager().register(this, new GeyserSpongeCommandExecutor(this.geyser, commands), entry.getKey().description().id());
+        }
     }
 
     @Override
@@ -139,7 +158,7 @@ public class GeyserSpongePlugin implements GeyserBootstrap {
     }
 
     @Override
-    public CommandManager getGeyserCommandManager() {
+    public GeyserCommandManager getGeyserCommandManager() {
         return this.geyserCommandManager;
     }
 
@@ -153,6 +172,11 @@ public class GeyserSpongePlugin implements GeyserBootstrap {
         return configDir.toPath();
     }
 
+    @Listener
+    public void onServerStarting() {
+        onLoad();
+    }
+
     @Listener
     public void onServerStart(GameStartedServerEvent event) {
         onEnable();
diff --git a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandExecutor.java b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandExecutor.java
index 825d0bf78..3598ea8c2 100644
--- a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandExecutor.java
+++ b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandExecutor.java
@@ -26,11 +26,12 @@
 package org.geysermc.geyser.platform.sponge.command;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandExecutor;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.api.command.Command;
 import org.geysermc.geyser.command.GeyserCommand;
-import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.command.GeyserCommandExecutor;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.spongepowered.api.command.CommandCallable;
 import org.spongepowered.api.command.CommandResult;
@@ -40,27 +41,24 @@ import org.spongepowered.api.world.Location;
 import org.spongepowered.api.world.World;
 
 import javax.annotation.Nullable;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
 
-public class GeyserSpongeCommandExecutor extends CommandExecutor implements CommandCallable {
+public class GeyserSpongeCommandExecutor extends GeyserCommandExecutor implements CommandCallable {
 
-    public GeyserSpongeCommandExecutor(GeyserImpl geyser) {
-        super(geyser);
+    public GeyserSpongeCommandExecutor(GeyserImpl geyser, Map<String, Command> commands) {
+        super(geyser, commands);
     }
 
     @Override
     public CommandResult process(CommandSource source, String arguments) {
-        CommandSender commandSender = new SpongeCommandSender(source);
+        GeyserCommandSource commandSender = new SpongeCommandSource(source);
         GeyserSession session = getGeyserSession(commandSender);
 
         String[] args = arguments.split(" ");
         if (args.length > 0) {
             GeyserCommand command = getCommand(args[0]);
             if (command != null) {
-                if (!source.hasPermission(command.getPermission())) {
+                if (!source.hasPermission(command.permission())) {
                     // Not ideal to use log here but we dont get a session
                     source.sendMessage(Text.of(ChatColor.RED + GeyserLocale.getLocaleStringLog("geyser.bootstrap.command.permission_fail")));
                     return CommandResult.success();
@@ -80,7 +78,7 @@ public class GeyserSpongeCommandExecutor extends CommandExecutor implements Comm
     @Override
     public List<String> getSuggestions(CommandSource source, String arguments, @Nullable Location<World> targetPosition) {
         if (arguments.split(" ").length == 1) {
-            return tabComplete(new SpongeCommandSender(source));
+            return tabComplete(new SpongeCommandSource(source));
         }
         return Collections.emptyList();
     }
diff --git a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandManager.java b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandManager.java
index dce39870d..8e981f72a 100644
--- a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandManager.java
+++ b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/GeyserSpongeCommandManager.java
@@ -26,12 +26,12 @@
 package org.geysermc.geyser.platform.sponge.command;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.spongepowered.api.Sponge;
 import org.spongepowered.api.command.CommandMapping;
 import org.spongepowered.api.text.Text;
 
-public class GeyserSpongeCommandManager extends CommandManager {
+public class GeyserSpongeCommandManager extends GeyserCommandManager {
     private final org.spongepowered.api.command.CommandManager handle;
 
     public GeyserSpongeCommandManager(org.spongepowered.api.command.CommandManager handle, GeyserImpl geyser) {
@@ -41,7 +41,7 @@ public class GeyserSpongeCommandManager extends CommandManager {
     }
 
     @Override
-    public String getDescription(String command) {
+    public String description(String command) {
         return handle.get(command).map(CommandMapping::getCallable)
                 .map(callable -> callable.getShortDescription(Sponge.getServer().getConsole()).orElse(Text.EMPTY))
                 .orElse(Text.EMPTY).toPlain();
diff --git a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/SpongeCommandSender.java b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/SpongeCommandSource.java
similarity index 94%
rename from bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/SpongeCommandSender.java
rename to bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/SpongeCommandSource.java
index f57f3e276..12fdcb989 100644
--- a/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/SpongeCommandSender.java
+++ b/bootstrap/sponge/src/main/java/org/geysermc/geyser/platform/sponge/command/SpongeCommandSource.java
@@ -26,14 +26,13 @@
 package org.geysermc.geyser.platform.sponge.command;
 
 import lombok.AllArgsConstructor;
-
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.spongepowered.api.command.CommandSource;
 import org.spongepowered.api.command.source.ConsoleSource;
 import org.spongepowered.api.text.Text;
 
 @AllArgsConstructor
-public class SpongeCommandSender implements CommandSender {
+public class SpongeCommandSource implements GeyserCommandSource {
 
     private CommandSource handle;
 
diff --git a/bootstrap/standalone/build.gradle.kts b/bootstrap/standalone/build.gradle.kts
new file mode 100644
index 000000000..3c1a10b09
--- /dev/null
+++ b/bootstrap/standalone/build.gradle.kts
@@ -0,0 +1,33 @@
+import com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer
+
+val terminalConsoleVersion = "1.2.0"
+val jlineVersion = "3.21.0"
+
+dependencies {
+    api(projects.core)
+
+    implementation("net.minecrell", "terminalconsoleappender", terminalConsoleVersion) {
+        exclude("org.apache.logging.log4j", "log4j-core")
+        exclude("org.jline", "jline-reader")
+        exclude("org.jline", "jline-terminal")
+        exclude("org.jline", "jline-terminal-jna")
+    }
+
+    implementation("org.jline", "jline-terminal", jlineVersion)
+    implementation("org.jline", "jline-terminal-jna", jlineVersion)
+    implementation("org.jline", "jline-reader", jlineVersion)
+
+    implementation("org.apache.logging.log4j", "log4j-api", Versions.log4jVersion)
+    implementation("org.apache.logging.log4j", "log4j-core", Versions.log4jVersion)
+    implementation("org.apache.logging.log4j", "log4j-slf4j18-impl", Versions.log4jVersion)
+}
+
+application {
+    mainClass.set("org.geysermc.geyser.platform.standalone.GeyserStandaloneBootstrap")
+}
+
+tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
+    archiveBaseName.set("Geyser-Standalone")
+
+    transform(Log4j2PluginsCacheFileTransformer())
+}
\ No newline at end of file
diff --git a/bootstrap/standalone/pom.xml b/bootstrap/standalone/pom.xml
deleted file mode 100644
index 5577f9206..000000000
--- a/bootstrap/standalone/pom.xml
+++ /dev/null
@@ -1,139 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>bootstrap-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>bootstrap-standalone</artifactId>
-
-    <properties>
-        <log4j.version>2.17.1</log4j.version>
-    </properties>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>core</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.minecrell</groupId>
-            <artifactId>terminalconsoleappender</artifactId>
-            <version>1.2.0</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>org.apache.logging.log4j</groupId>
-                    <artifactId>log4j-core</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>org.jline</groupId>
-                    <artifactId>jline-reader</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>org.jline</groupId>
-                    <artifactId>jline-terminal-jna</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>org.jline</groupId>
-                    <artifactId>jline-terminal</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>org.jline</groupId>
-            <artifactId>jline-terminal</artifactId>
-            <version>3.21.0</version>
-        </dependency>
-        <dependency>
-            <groupId>org.jline</groupId>
-            <artifactId>jline-terminal-jna</artifactId>
-            <version>3.21.0</version>
-        </dependency>
-        <dependency>
-            <groupId>org.jline</groupId>
-            <artifactId>jline-reader</artifactId>
-            <version>3.21.0</version>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.logging.log4j</groupId>
-            <artifactId>log4j-api</artifactId>
-            <version>${log4j.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.logging.log4j</groupId>
-            <artifactId>log4j-core</artifactId>
-            <version>${log4j.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.logging.log4j</groupId>
-            <artifactId>log4j-slf4j18-impl</artifactId>
-            <version>${log4j.version}</version>
-        </dependency>
-    </dependencies>
-    <build>
-        <finalName>${outputName}</finalName>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-                <version>3.2.0</version>
-                <configuration>
-                    <archive>
-                        <manifestEntries>
-                            <Main-Class>org.geysermc.geyser.platform.standalone.GeyserStandaloneBootstrap</Main-Class>
-                        </manifestEntries>
-                    </archive>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-shade-plugin</artifactId>
-                <version>3.3.0</version>
-                <dependencies>
-                    <dependency>
-                        <groupId>com.github.edwgiz</groupId>
-                        <artifactId>maven-shade-plugin.log4j2-cachefile-transformer</artifactId>
-                        <version>2.8.1</version>
-                    </dependency>
-                </dependencies>
-                <executions>
-                    <execution>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>shade</goal>
-                        </goals>
-                        <configuration>
-                            <minimizeJar>false</minimizeJar>
-                        </configuration>
-                    </execution>
-                </executions>
-                <configuration>
-                    <filters>
-                        <filter>
-                            <artifact>*:*</artifact>
-                            <excludes>
-                                <exclude>META-INF/versions/9/module-info.class</exclude>
-                            </excludes>
-                        </filter>
-                    </filters>
-                    <transformers>
-                        <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
-                            <mainClass>org.geysermc.geyser.platform.standalone.GeyserStandaloneBootstrap</mainClass>
-                            <manifestEntries>
-                                <Multi-Release>true</Multi-Release>
-                            </manifestEntries>
-                        </transformer>
-                        <transformer
-                                implementation="com.github.edwgiz.mavenShadePlugin.log4j2CacheTransformer.PluginsCacheFileTransformer">
-                        </transformer>
-                    </transformers>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-</project>
diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java
index ca41fbd72..80d17f6a7 100644
--- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java
+++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.java
@@ -39,18 +39,18 @@ import org.apache.logging.log4j.core.Appender;
 import org.apache.logging.log4j.core.Logger;
 import org.apache.logging.log4j.core.appender.ConsoleAppender;
 import org.geysermc.common.PlatformType;
-import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserBootstrap;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.configuration.GeyserJacksonConfiguration;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
 import org.geysermc.geyser.ping.GeyserLegacyPingPassthrough;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
-import org.geysermc.geyser.util.FileUtils;
-import org.geysermc.geyser.text.GeyserLocale;
-import org.geysermc.geyser.platform.standalone.command.GeyserCommandManager;
+import org.geysermc.geyser.platform.standalone.command.GeyserStandaloneCommandManager;
 import org.geysermc.geyser.platform.standalone.gui.GeyserStandaloneGUI;
+import org.geysermc.geyser.text.GeyserLocale;
+import org.geysermc.geyser.util.FileUtils;
 import org.geysermc.geyser.util.LoopbackUtil;
 
 import java.io.File;
@@ -64,7 +64,7 @@ import java.util.stream.Collectors;
 
 public class GeyserStandaloneBootstrap implements GeyserBootstrap {
 
-    private GeyserCommandManager geyserCommandManager;
+    private GeyserStandaloneCommandManager geyserCommandManager;
     private GeyserStandaloneConfiguration geyserConfig;
     private GeyserStandaloneLogger geyserLogger;
     private IGeyserPingPassthrough geyserPingPassthrough;
@@ -180,6 +180,7 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap {
                 logger.removeAppender(appender);
             }
         }
+
         if (useGui && gui == null) {
             gui = new GeyserStandaloneGUI();
             gui.redirectSystemStreams();
@@ -197,7 +198,7 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap {
 
             handleArgsConfigOptions();
 
-            if (this.geyserConfig.getRemote().getAddress().equalsIgnoreCase("auto")) {
+            if (this.geyserConfig.getRemote().address().equalsIgnoreCase("auto")) {
                 geyserConfig.setAutoconfiguredRemote(true); // Doesn't really need to be set but /shrug
                 geyserConfig.getRemote().setAddress("127.0.0.1");
             }
@@ -216,8 +217,11 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap {
         // Allow libraries like Protocol to have their debug information passthrough
         logger.get().setLevel(geyserConfig.isDebugMode() ? Level.DEBUG : Level.INFO);
 
-        geyser = GeyserImpl.start(PlatformType.STANDALONE, this);
-        geyserCommandManager = new GeyserCommandManager(geyser);
+        geyser = GeyserImpl.load(PlatformType.STANDALONE, this);
+        GeyserImpl.start();
+
+        geyserCommandManager = new GeyserStandaloneCommandManager(geyser);
+        geyserCommandManager.init();
 
         if (gui != null) {
             gui.setupInterface(geyserLogger, geyserCommandManager);
@@ -262,7 +266,7 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap {
     }
 
     @Override
-    public CommandManager getGeyserCommandManager() {
+    public GeyserCommandManager getGeyserCommandManager() {
         return geyserCommandManager;
     }
 
diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java
index 78e603d7c..e7e24a465 100644
--- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java
+++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java
@@ -25,16 +25,17 @@
 
 package org.geysermc.geyser.platform.standalone;
 
-import lombok.extern.log4j.Log4j2;
+import lombok.extern.slf4j.Slf4j;
 import net.minecrell.terminalconsole.SimpleTerminalConsole;
 import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.core.config.Configurator;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.text.ChatColor;
 
-@Log4j2
-public class GeyserStandaloneLogger extends SimpleTerminalConsole implements GeyserLogger {
+@Slf4j
+public class GeyserStandaloneLogger extends SimpleTerminalConsole implements GeyserLogger, GeyserCommandSource {
 
     @Override
     protected boolean isRunning() {
@@ -43,7 +44,7 @@ public class GeyserStandaloneLogger extends SimpleTerminalConsole implements Gey
 
     @Override
     protected void runCommand(String line) {
-        GeyserImpl.getInstance().getCommandManager().runCommand(this, line);
+        GeyserImpl.getInstance().commandManager().runCommand(this, line);
     }
 
     @Override
@@ -53,12 +54,12 @@ public class GeyserStandaloneLogger extends SimpleTerminalConsole implements Gey
 
     @Override
     public void severe(String message) {
-        log.fatal(ChatColor.DARK_RED + message);
+        log.error(ChatColor.DARK_RED + message);
     }
 
     @Override
     public void severe(String message, Throwable error) {
-        log.fatal(ChatColor.DARK_RED + message, error);
+        log.error(ChatColor.DARK_RED + message, error);
     }
 
     @Override
diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/command/GeyserCommandManager.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/command/GeyserStandaloneCommandManager.java
similarity index 85%
rename from bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/command/GeyserCommandManager.java
rename to bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/command/GeyserStandaloneCommandManager.java
index 03d780f3c..e7b4cbe37 100644
--- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/command/GeyserCommandManager.java
+++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/command/GeyserStandaloneCommandManager.java
@@ -26,16 +26,16 @@
 package org.geysermc.geyser.platform.standalone.command;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.command.GeyserCommandManager;
 
-public class GeyserCommandManager extends CommandManager {
+public class GeyserStandaloneCommandManager extends GeyserCommandManager {
 
-    public GeyserCommandManager(GeyserImpl geyser) {
+    public GeyserStandaloneCommandManager(GeyserImpl geyser) {
         super(geyser);
     }
 
     @Override
-    public String getDescription(String command) {
+    public String description(String command) {
         return ""; // this is not sent over the protocol, so we return none
     }
 }
diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/gui/GeyserStandaloneGUI.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/gui/GeyserStandaloneGUI.java
index 44faabdf5..a8bce303f 100644
--- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/gui/GeyserStandaloneGUI.java
+++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/gui/GeyserStandaloneGUI.java
@@ -26,11 +26,12 @@
 package org.geysermc.geyser.platform.standalone.gui;
 
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.command.Command;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.platform.standalone.GeyserStandaloneLogger;
+import org.geysermc.geyser.platform.standalone.command.GeyserStandaloneCommandManager;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
-import org.geysermc.geyser.platform.standalone.GeyserStandaloneLogger;
-import org.geysermc.geyser.platform.standalone.command.GeyserCommandManager;
 
 import javax.swing.*;
 import javax.swing.table.DefaultTableModel;
@@ -255,33 +256,34 @@ public class GeyserStandaloneGUI {
      * @param geyserStandaloneLogger The current logger
      * @param geyserCommandManager The commands manager
      */
-    public void setupInterface(GeyserStandaloneLogger geyserStandaloneLogger, GeyserCommandManager geyserCommandManager) {
+    public void setupInterface(GeyserStandaloneLogger geyserStandaloneLogger, GeyserStandaloneCommandManager geyserCommandManager) {
         commandsMenu.removeAll();
         optionsMenu.removeAll();
 
-        for (Map.Entry<String, GeyserCommand> command : geyserCommandManager.getCommands().entrySet()) {
+        for (Map.Entry<String, Command> entry : geyserCommandManager.getCommands().entrySet()) {
             // Remove the offhand command and any alias commands to prevent duplicates in the list
-            if (!command.getValue().isExecutableOnConsole() || command.getValue().getAliases().contains(command.getKey())) {
+            if (!entry.getValue().isExecutableOnConsole() || entry.getValue().aliases().contains(entry.getKey())) {
                 continue;
             }
 
+            GeyserCommand command = (GeyserCommand) entry.getValue();
             // Create the button that runs the command
-            boolean hasSubCommands = command.getValue().hasSubCommands();
+            boolean hasSubCommands = !entry.getValue().subCommands().isEmpty();
             // Add an extra menu if there are more commands that can be run
-            JMenuItem commandButton = hasSubCommands ? new JMenu(command.getValue().getName()) : new JMenuItem(command.getValue().getName());
-            commandButton.getAccessibleContext().setAccessibleDescription(command.getValue().getDescription());
+            JMenuItem commandButton = hasSubCommands ? new JMenu(entry.getValue().name()) : new JMenuItem(entry.getValue().name());
+            commandButton.getAccessibleContext().setAccessibleDescription(entry.getValue().description());
             if (!hasSubCommands) {
-                commandButton.addActionListener(e -> command.getValue().execute(null, geyserStandaloneLogger, new String[]{ }));
+                commandButton.addActionListener(e -> command.execute(null, geyserStandaloneLogger, new String[]{ }));
             } else {
                 // Add a submenu that's the same name as the menu can't be pressed
-                JMenuItem otherCommandButton = new JMenuItem(command.getValue().getName());
-                otherCommandButton.getAccessibleContext().setAccessibleDescription(command.getValue().getDescription());
-                otherCommandButton.addActionListener(e -> command.getValue().execute(null, geyserStandaloneLogger, new String[]{ }));
+                JMenuItem otherCommandButton = new JMenuItem(entry.getValue().name());
+                otherCommandButton.getAccessibleContext().setAccessibleDescription(entry.getValue().description());
+                otherCommandButton.addActionListener(e -> command.execute(null, geyserStandaloneLogger, new String[]{ }));
                 commandButton.add(otherCommandButton);
                 // Add a menu option for all possible subcommands
-                for (String subCommandName : command.getValue().getSubCommands()) {
+                for (String subCommandName : entry.getValue().subCommands()) {
                     JMenuItem item = new JMenuItem(subCommandName);
-                    item.addActionListener(e -> command.getValue().execute(null, geyserStandaloneLogger, new String[]{subCommandName}));
+                    item.addActionListener(e -> command.execute(null, geyserStandaloneLogger, new String[]{subCommandName}));
                     commandButton.add(item);
                 }
             }
diff --git a/bootstrap/standalone/src/main/resources/log4j2.xml b/bootstrap/standalone/src/main/resources/log4j2.xml
index cd101f306..0738acdcd 100644
--- a/bootstrap/standalone/src/main/resources/log4j2.xml
+++ b/bootstrap/standalone/src/main/resources/log4j2.xml
@@ -16,7 +16,7 @@
         </RollingRandomAccessFile>
     </Appenders>
     <Loggers>
-        <Root level="INFO">
+        <Root level="INFO" >
             <AppenderRef ref="TerminalConsole"/>
             <AppenderRef ref="Console"/>
             <AppenderRef ref="File"/>
diff --git a/bootstrap/velocity/build.gradle.kts b/bootstrap/velocity/build.gradle.kts
new file mode 100644
index 000000000..ab2f85b85
--- /dev/null
+++ b/bootstrap/velocity/build.gradle.kts
@@ -0,0 +1,69 @@
+val velocityVersion = "3.0.0"
+
+dependencies {
+    annotationProcessor("com.velocitypowered", "velocity-api", velocityVersion)
+    api(projects.core)
+}
+
+platformRelocate("com.fasterxml.jackson")
+platformRelocate("it.unimi.dsi.fastutil")
+platformRelocate("net.kyori.adventure.text.serializer.gson.legacyimpl")
+
+exclude("com.google.*:*")
+
+// Needed because Velocity provides every dependency except netty-resolver-dns 
+exclude("io.netty:netty-transport-native-epoll:*")
+exclude("io.netty:netty-transport-native-unix-common:*")
+exclude("io.netty:netty-transport-native-kqueue:*")
+exclude("io.netty:netty-handler:*")
+exclude("io.netty:netty-common:*")
+exclude("io.netty:netty-buffer:*")
+exclude("io.netty:netty-resolver:*")
+exclude("io.netty:netty-transport:*")
+exclude("io.netty:netty-codec:*")
+exclude("io.netty:netty-codec-haproxy:*")
+exclude("org.slf4j:*")
+exclude("org.ow2.asm:*")
+
+// Exclude all Kyori dependencies except the legacy NBT serializer 
+exclude("net.kyori:adventure-api:*")
+exclude("net.kyori:examination-api:*")
+exclude("net.kyori:examination-string:*")
+exclude("net.kyori:adventure-text-serializer-gson:*")
+exclude("net.kyori:adventure-text-serializer-legacy:*")
+exclude("net.kyori:adventure-nbt:*")
+        
+// These dependencies are already present on the platform
+provided("com.velocitypowered", "velocity-api", velocityVersion)
+
+application {
+    mainClass.set("org.geysermc.geyser.platform.velocity.GeyserVelocityMain")
+}
+
+tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
+    archiveBaseName.set("Geyser-Velocity")
+
+    dependencies {
+        exclude(dependency("com.google.*:.*"))
+        // Needed because Velocity provides every dependency except netty-resolver-dns
+        exclude(dependency("io.netty:netty-transport-native-epoll:.*"))
+        exclude(dependency("io.netty:netty-transport-native-unix-common:.*"))
+        exclude(dependency("io.netty:netty-transport-native-kqueue:.*"))
+        exclude(dependency("io.netty:netty-handler:.*"))
+        exclude(dependency("io.netty:netty-common:.*"))
+        exclude(dependency("io.netty:netty-buffer:.*"))
+        exclude(dependency("io.netty:netty-resolver:.*"))
+        exclude(dependency("io.netty:netty-transport:.*"))
+        exclude(dependency("io.netty:netty-codec:.*"))
+        exclude(dependency("io.netty:netty-codec-haproxy:.*"))
+        exclude(dependency("org.slf4j:.*"))
+        exclude(dependency("org.ow2.asm:.*"))
+        // Exclude all Kyori dependencies except the legacy NBT serializer
+        exclude(dependency("net.kyori:adventure-api:.*"))
+        exclude(dependency("net.kyori:examination-api:.*"))
+        exclude(dependency("net.kyori:examination-string:.*"))
+        exclude(dependency("net.kyori:adventure-text-serializer-gson:.*"))
+        exclude(dependency("net.kyori:adventure-text-serializer-legacy:.*"))
+        exclude(dependency("net.kyori:adventure-nbt:.*"))
+    }
+}
\ No newline at end of file
diff --git a/bootstrap/velocity/pom.xml b/bootstrap/velocity/pom.xml
deleted file mode 100644
index 35e6df15b..000000000
--- a/bootstrap/velocity/pom.xml
+++ /dev/null
@@ -1,106 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>bootstrap-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>bootstrap-velocity</artifactId>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>core</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.velocitypowered</groupId>
-            <artifactId>velocity-api</artifactId>
-            <version>3.0.0</version>
-            <scope>provided</scope>
-        </dependency>
-    </dependencies>
-    <build>
-        <finalName>${outputName}-Velocity</finalName>
-        <resources>
-            <resource>
-                <directory>src/main/resources/</directory>
-                <filtering>true</filtering>
-            </resource>
-        </resources>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-                <version>3.2.0</version>
-                <configuration>
-                    <archive>
-                        <manifestEntries>
-                            <Main-Class>org.geysermc.geyser.platform.velocity.GeyserVelocityMain</Main-Class>
-                        </manifestEntries>
-                    </archive>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-shade-plugin</artifactId>
-                <version>3.3.0</version>
-                <executions>
-                    <execution>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>shade</goal>
-                        </goals>
-                        <configuration>
-                            <relocations>
-                                <relocation>
-                                    <pattern>com.fasterxml.jackson</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.velocity.shaded.jackson</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>it.unimi.dsi.fastutil</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.velocity.shaded.fastutil</shadedPattern>
-                                </relocation>
-                                <relocation>
-                                    <pattern>net.kyori.adventure.text.serializer.gson.legacyimpl</pattern>
-                                    <shadedPattern>org.geysermc.geyser.platform.velocity.shaded.kyori.legacyimpl</shadedPattern>
-                                </relocation>
-                            </relocations>
-                        </configuration>
-                    </execution>
-                </executions>
-                <configuration>
-                    <artifactSet>
-                        <excludes>
-                            <exclude>com.google.*:*</exclude>
-                            <!-- Needed because Velocity provides every dependency except netty-resolver-dns -->
-                            <exclude>io.netty:netty-transport-native-epoll:*</exclude>
-                            <exclude>io.netty:netty-transport-native-unix-common:*</exclude>
-                            <exclude>io.netty:netty-transport-native-kqueue:*</exclude>
-                            <exclude>io.netty:netty-handler:*</exclude>
-                            <exclude>io.netty:netty-common:*</exclude>
-                            <exclude>io.netty:netty-buffer:*</exclude>
-                            <exclude>io.netty:netty-resolver:*</exclude>
-                            <exclude>io.netty:netty-transport:*</exclude>
-                            <exclude>io.netty:netty-codec:*</exclude>
-                            <exclude>io.netty:netty-codec-haproxy:*</exclude>
-                            <exclude>org.slf4j:*</exclude>
-                            <exclude>org.ow2.asm:*</exclude>
-                            <!-- Exclude all Kyori dependencies except the legacy NBT serializer -->
-                            <exclude>net.kyori:adventure-api:*</exclude>
-                            <exclude>net.kyori:examination-api:*</exclude>
-                            <exclude>net.kyori:examination-string:*</exclude>
-                            <exclude>net.kyori:adventure-text-serializer-gson:*</exclude>
-                            <exclude>net.kyori:adventure-text-serializer-legacy:*</exclude>
-                            <exclude>net.kyori:adventure-nbt:*</exclude>
-                        </excludes>
-                    </artifactSet>
-                </configuration>
-            </plugin>
-        </plugins>
-    </build>
-</project>
\ No newline at end of file
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityDumpInfo.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityDumpInfo.java
index ffc7db291..9f429cc83 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityDumpInfo.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityDumpInfo.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.platform.velocity;
 import com.velocitypowered.api.plugin.PluginContainer;
 import com.velocitypowered.api.proxy.ProxyServer;
 import lombok.Getter;
-import org.geysermc.geyser.text.AsteriskSerializer;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
+import org.geysermc.geyser.text.AsteriskSerializer;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java
index 13a07121e..dc31b3fdd 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java
@@ -39,13 +39,16 @@ import net.kyori.adventure.util.Codec;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserBootstrap;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.network.AuthType;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
 import org.geysermc.geyser.ping.GeyserLegacyPingPassthrough;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
 import org.geysermc.geyser.platform.velocity.command.GeyserVelocityCommandExecutor;
 import org.geysermc.geyser.platform.velocity.command.GeyserVelocityCommandManager;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.FileUtils;
 import org.jetbrains.annotations.Nullable;
@@ -57,6 +60,7 @@ import java.net.InetSocketAddress;
 import java.net.SocketAddress;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Map;
 import java.util.UUID;
 
 @Plugin(id = "geyser", name = GeyserImpl.NAME + "-Velocity", version = GeyserImpl.VERSION, url = "https://geysermc.org", authors = "GeyserMC")
@@ -84,15 +88,6 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
 
     @Override
     public void onEnable() {
-        try {
-            Codec.class.getMethod("codec", Codec.Decoder.class, Codec.Encoder.class);
-        } catch (NoSuchMethodException e) {
-            // velocitypowered.com has a build that is very outdated
-            logger.error("Please download Velocity from https://papermc.io/downloads#Velocity - the 'stable' Velocity version " +
-                    "that has likely been downloaded is very outdated and does not support 1.19.");
-            return;
-        }
-
         GeyserLocale.init(this);
 
         try {
@@ -111,7 +106,7 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
         InetSocketAddress javaAddr = proxyServer.getBoundAddress();
 
         // By default this should be localhost but may need to be changed in some circumstances
-        if (this.geyserConfig.getRemote().getAddress().equalsIgnoreCase("auto")) {
+        if (this.geyserConfig.getRemote().address().equalsIgnoreCase("auto")) {
             this.geyserConfig.setAutoconfiguredRemote(true);
             // Don't use localhost if not listening on all interfaces
             if (!javaAddr.getHostString().equals("0.0.0.0") && !javaAddr.getHostString().equals("")) {
@@ -127,6 +122,17 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
         this.geyserLogger = new GeyserVelocityLogger(logger, geyserConfig.isDebugMode());
         GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
 
+        this.geyser = GeyserImpl.load(PlatformType.VELOCITY, this);
+
+        try {
+            Codec.class.getMethod("codec", Codec.Decoder.class, Codec.Encoder.class);
+        } catch (NoSuchMethodException e) {
+            // velocitypowered.com has a build that is very outdated
+            logger.error("Please download Velocity from https://papermc.io/downloads#Velocity - the 'stable' Velocity version " +
+                    "that has likely been downloaded is very outdated and does not support 1.19.");
+            return;
+        }
+
         // Remove this in like a year
         try {
             // Should only exist on 1.0
@@ -137,7 +143,7 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
         } catch (ClassNotFoundException ignored) {
         }
 
-        if (geyserConfig.getRemote().getAuthType() == AuthType.FLOODGATE && proxyServer.getPluginManager().getPlugin("floodgate").isEmpty()) {
+        if (geyserConfig.getRemote().authType() == AuthType.FLOODGATE && proxyServer.getPluginManager().getPlugin("floodgate").isEmpty()) {
             geyserLogger.severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.not_installed") + " "
                     + GeyserLocale.getLocaleStringLog("geyser.bootstrap.floodgate.disabling"));
             return;
@@ -149,13 +155,27 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
 
         geyserConfig.loadFloodgate(this, proxyServer, configFolder.toFile());
 
-        this.geyser = GeyserImpl.start(PlatformType.VELOCITY, this);
+    }
+
+    private void postStartup() {
+        GeyserImpl.start();
 
         this.geyserInjector = new GeyserVelocityInjector(proxyServer);
         // Will be initialized after the proxy has been bound
 
         this.geyserCommandManager = new GeyserVelocityCommandManager(geyser);
-        this.commandManager.register("geyser", new GeyserVelocityCommandExecutor(geyser));
+        this.geyserCommandManager.init();
+
+        this.commandManager.register("geyser", new GeyserVelocityCommandExecutor(geyser, geyserCommandManager.getCommands()));
+        for (Map.Entry<Extension, Map<String, Command>> entry : this.geyserCommandManager.extensionCommands().entrySet()) {
+            Map<String, Command> commands = entry.getValue();
+            if (commands.isEmpty()) {
+                continue;
+            }
+
+            this.commandManager.register(entry.getKey().description().id(), new GeyserVelocityCommandExecutor(this.geyser, commands));
+        }
+
         if (geyserConfig.isLegacyPingPassthrough()) {
             this.geyserPingPassthrough = GeyserLegacyPingPassthrough.init(geyser);
         } else {
@@ -186,7 +206,7 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
     }
 
     @Override
-    public org.geysermc.geyser.command.CommandManager getGeyserCommandManager() {
+    public GeyserCommandManager getGeyserCommandManager() {
         return this.geyserCommandManager;
     }
 
@@ -207,9 +227,14 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
 
     @Subscribe
     public void onProxyBound(ListenerBoundEvent event) {
-        if (event.getListenerType() == ListenerType.MINECRAFT && geyserInjector != null) {
-            // After this bound, we know that the channel initializer cannot change without it being ineffective for Velocity, too
-            geyserInjector.initializeLocalChannel(this);
+        if (event.getListenerType() == ListenerType.MINECRAFT) {
+            // Once listener is bound, do our startup process
+            this.postStartup();
+
+            if (geyserInjector != null) {
+                // After this bound, we know that the channel initializer cannot change without it being ineffective for Velocity, too
+                geyserInjector.initializeLocalChannel(this);
+            }
         }
     }
 
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java
index 506dfff71..31e584612 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java
@@ -30,7 +30,7 @@ import com.velocitypowered.api.event.connection.PostLoginEvent;
 import com.velocitypowered.api.proxy.Player;
 import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.platform.velocity.command.VelocityCommandSender;
+import org.geysermc.geyser.platform.velocity.command.VelocityCommandSource;
 import org.geysermc.geyser.util.VersionCheckUtils;
 
 public final class GeyserVelocityUpdateListener {
@@ -40,7 +40,7 @@ public final class GeyserVelocityUpdateListener {
         if (GeyserImpl.getInstance().getConfig().isNotifyOnNewBedrockUpdate()) {
             final Player player = event.getPlayer();
             if (player.hasPermission(Constants.UPDATE_PERMISSION)) {
-                VersionCheckUtils.checkForGeyserUpdate(() -> new VelocityCommandSender(player));
+                VersionCheckUtils.checkForGeyserUpdate(() -> new VelocityCommandSource(player));
             }
         }
     }
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandExecutor.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandExecutor.java
index 30f6c2efd..c77a3daef 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandExecutor.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandExecutor.java
@@ -27,37 +27,39 @@ package org.geysermc.geyser.platform.velocity.command;
 
 import com.velocitypowered.api.command.SimpleCommand;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandExecutor;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.api.command.Command;
 import org.geysermc.geyser.command.GeyserCommand;
-import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.command.GeyserCommandExecutor;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
-public class GeyserVelocityCommandExecutor extends CommandExecutor implements SimpleCommand {
+public class GeyserVelocityCommandExecutor extends GeyserCommandExecutor implements SimpleCommand {
 
-    public GeyserVelocityCommandExecutor(GeyserImpl geyser) {
-        super(geyser);
+    public GeyserVelocityCommandExecutor(GeyserImpl geyser, Map<String, Command> commands) {
+        super(geyser, commands);
     }
 
     @Override
     public void execute(Invocation invocation) {
-        CommandSender sender = new VelocityCommandSender(invocation.source());
+        GeyserCommandSource sender = new VelocityCommandSource(invocation.source());
         GeyserSession session = getGeyserSession(sender);
 
         if (invocation.arguments().length > 0) {
             GeyserCommand command = getCommand(invocation.arguments()[0]);
             if (command != null) {
-                if (!invocation.source().hasPermission(getCommand(invocation.arguments()[0]).getPermission())) {
-                    sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.getLocale()));
+                if (!invocation.source().hasPermission(getCommand(invocation.arguments()[0]).permission())) {
+                    sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.locale()));
                     return;
                 }
                 if (command.isBedrockOnly() && session == null) {
-                    sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.bedrock_only", sender.getLocale()));
+                    sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.bedrock_only", sender.locale()));
                     return;
                 }
                 command.execute(session, sender, invocation.arguments().length > 1 ? Arrays.copyOfRange(invocation.arguments(), 1, invocation.arguments().length) : new String[0]);
@@ -71,7 +73,7 @@ public class GeyserVelocityCommandExecutor extends CommandExecutor implements Si
     public List<String> suggest(Invocation invocation) {
         // Velocity seems to do the splitting a bit differently. This results in the same behaviour in bungeecord/spigot.
         if (invocation.arguments().length == 0 || invocation.arguments().length == 1) {
-            return tabComplete(new VelocityCommandSender(invocation.source()));
+            return tabComplete(new VelocityCommandSource(invocation.source()));
         }
         return Collections.emptyList();
     }
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandManager.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandManager.java
index b42c8f76e..6f9faba8f 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandManager.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/GeyserVelocityCommandManager.java
@@ -26,16 +26,16 @@
 package org.geysermc.geyser.platform.velocity.command;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.command.GeyserCommandManager;
 
-public class GeyserVelocityCommandManager extends CommandManager {
+public class GeyserVelocityCommandManager extends GeyserCommandManager {
 
     public GeyserVelocityCommandManager(GeyserImpl geyser) {
         super(geyser);
     }
 
     @Override
-    public String getDescription(String command) {
+    public String description(String command) {
         return ""; // no support for command descriptions in velocity
     }
 }
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSource.java
similarity index 91%
rename from bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java
rename to bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSource.java
index a5474c3e0..00c99e92b 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSource.java
@@ -30,19 +30,19 @@ import com.velocitypowered.api.proxy.ConsoleCommandSource;
 import com.velocitypowered.api.proxy.Player;
 import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.Locale;
 
-public class VelocityCommandSender implements CommandSender {
+public class VelocityCommandSource implements GeyserCommandSource {
 
     private final CommandSource handle;
 
-    public VelocityCommandSender(CommandSource handle) {
+    public VelocityCommandSource(CommandSource handle) {
         this.handle = handle;
         // Ensure even Java players' languages are loaded
-        GeyserLocale.loadGeyserLocale(getLocale());
+        GeyserLocale.loadGeyserLocale(this.locale());
     }
 
     @Override
@@ -72,7 +72,7 @@ public class VelocityCommandSender implements CommandSender {
     }
 
     @Override
-    public String getLocale() {
+    public String locale() {
         if (handle instanceof Player) {
             Locale locale = ((Player) handle).getPlayerSettings().getLocale();
             return GeyserLocale.formatLocale(locale.getLanguage() + "_" + locale.getCountry());
diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts
new file mode 100644
index 000000000..25cbfe9de
--- /dev/null
+++ b/build-logic/build.gradle.kts
@@ -0,0 +1,21 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+    `kotlin-dsl`
+}
+
+repositories {
+    gradlePluginPortal()
+}
+
+dependencies {
+    implementation("net.kyori", "indra-common", "2.0.6")
+    implementation("org.jfrog.buildinfo", "build-info-extractor-gradle", "4.26.1")
+    implementation("gradle.plugin.com.github.johnrengelman", "shadow", "7.1.1")
+}
+
+tasks.withType<KotlinCompile> {
+    kotlinOptions {
+        jvmTarget = "16"
+    }
+}
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/Versions.kt b/build-logic/src/main/kotlin/Versions.kt
new file mode 100644
index 000000000..47f35726d
--- /dev/null
+++ b/build-logic/src/main/kotlin/Versions.kt
@@ -0,0 +1,46 @@
+/*
+ * 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
+ */
+
+object Versions {
+    const val jacksonVersion = "2.13.2"
+    const val fastutilVersion = "8.5.2"
+    const val nettyVersion = "4.1.80.Final"
+    const val guavaVersion = "29.0-jre"
+    const val gsonVersion = "2.3.1" // Provided by Spigot 1.8.8
+    const val nbtVersion = "2.1.0"
+    const val websocketVersion = "1.5.1"
+    const val protocolVersion = "fed46166"
+    const val raknetVersion = "1.6.28-20220125.214016-6"
+    const val mcauthlibVersion = "d9d773e"
+    const val mcprotocollibversion = "9f78bd5"
+    const val packetlibVersion = "3.0"
+    const val adventureVersion = "4.12.0-20220629.025215-9"
+    const val adventurePlatformVersion = "4.1.2"
+    const val junitVersion = "4.13.1"
+    const val checkerQualVersion = "3.19.0"
+    const val cumulusVersion  = "1.1.1"
+    const val eventsVersion = "1.0-SNAPSHOT"
+    const val log4jVersion  = "2.17.1"
+}
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/extensions.kt b/build-logic/src/main/kotlin/extensions.kt
new file mode 100644
index 000000000..43cdafdcc
--- /dev/null
+++ b/build-logic/src/main/kotlin/extensions.kt
@@ -0,0 +1,68 @@
+/*
+ * 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
+ */
+
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ProjectDependency
+import org.gradle.kotlin.dsl.named
+
+fun Project.isSnapshot(): Boolean =
+    version.toString().endsWith("-SNAPSHOT")
+
+fun Project.relocate(pattern: String) {
+    tasks.named<ShadowJar>("shadowJar") {
+        relocate(pattern, "org.geysermc.geyser.shaded.$pattern")
+    }
+}
+
+fun Project.exclude(group: String) {
+    tasks.named<ShadowJar>("shadowJar") {
+        exclude(group)
+    }
+}
+
+fun Project.platformRelocate(pattern: String, exclusion: String = "") {
+    tasks.named<ShadowJar>("shadowJar") {
+        relocate(pattern, "org.geysermc.geyser.platform.${project.name}.shaded.$pattern") {
+            exclude(exclusion)
+        }
+    }
+}
+
+val providedDependencies = mutableMapOf<String, MutableSet<String>>()
+
+fun Project.provided(pattern: String, name: String, version: String, excludedOn: Int = 0b110) {
+    providedDependencies.getOrPut(project.name) { mutableSetOf() }
+        .add("${calcExclusion(pattern, 0b100, excludedOn)}:" +
+                "${calcExclusion(name, 0b10, excludedOn)}:" +
+                calcExclusion(version, 0b1, excludedOn))
+    dependencies.add("compileOnlyApi", "$pattern:$name:$version")
+}
+
+fun Project.provided(dependency: ProjectDependency) =
+    provided(dependency.group!!, dependency.name, dependency.version!!)
+
+private fun calcExclusion(section: String, bit: Int, excludedOn: Int): String =
+    if (excludedOn and bit > 0) section else ""
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/geyser.api-conventions.gradle.kts b/build-logic/src/main/kotlin/geyser.api-conventions.gradle.kts
new file mode 100644
index 000000000..7c8f9a3d7
--- /dev/null
+++ b/build-logic/src/main/kotlin/geyser.api-conventions.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+    id("geyser.publish-conventions")
+}
+
+tasks {
+    shadowJar {
+        archiveBaseName.set(archiveBaseName.get() + "-api")
+    }
+}
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/geyser.base-conventions.gradle.kts b/build-logic/src/main/kotlin/geyser.base-conventions.gradle.kts
new file mode 100644
index 000000000..2ea5d88a4
--- /dev/null
+++ b/build-logic/src/main/kotlin/geyser.base-conventions.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+    `java-library`
+    `maven-publish`
+}
+
+dependencies {
+    compileOnly("org.checkerframework", "checker-qual", Versions.checkerQualVersion)
+}
+
+tasks {
+    processResources {
+        filesMatching(listOf("plugin.yml", "bungee.yml", "velocity-plugin.json")) {
+            expand(
+                "id" to "Geyser",
+                "name" to "Geyser",
+                "version" to project.version,
+                "description" to project.description,
+                "url" to "https://geysermc.org",
+                "author" to "GeyserMC"
+            )
+        }
+    }
+    compileJava {
+        options.encoding = Charsets.UTF_8.name()
+    }
+}
+
+java {
+    sourceCompatibility = JavaVersion.VERSION_16
+    targetCompatibility = JavaVersion.VERSION_16
+
+    withSourcesJar()
+}
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/geyser.build-logic.gradle.kts b/build-logic/src/main/kotlin/geyser.build-logic.gradle.kts
new file mode 100644
index 000000000..e69de29bb
diff --git a/build-logic/src/main/kotlin/geyser.platform-conventions.gradle.kts b/build-logic/src/main/kotlin/geyser.platform-conventions.gradle.kts
new file mode 100644
index 000000000..81d224906
--- /dev/null
+++ b/build-logic/src/main/kotlin/geyser.platform-conventions.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+    application
+    id("geyser.publish-conventions")
+}
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/geyser.publish-conventions.gradle.kts b/build-logic/src/main/kotlin/geyser.publish-conventions.gradle.kts
new file mode 100644
index 000000000..f1cb8b139
--- /dev/null
+++ b/build-logic/src/main/kotlin/geyser.publish-conventions.gradle.kts
@@ -0,0 +1,31 @@
+plugins {
+    id("geyser.shadow-conventions")
+    id("com.jfrog.artifactory")
+    id("maven-publish")
+}
+
+publishing {
+    publications.create<MavenPublication>("mavenJava") {
+        groupId = project.group as String
+        artifactId = project.name
+        version = project.version as String
+
+        artifact(tasks["shadowJar"])
+        artifact(tasks["sourcesJar"])
+    }
+}
+
+artifactory {
+    publish {
+        repository {
+            setRepoKey(if (isSnapshot()) "maven-snapshots" else "maven-releases")
+            setMavenCompatible(true)
+        }
+        defaults {
+            publishConfigs("archives")
+            setPublishArtifacts(true)
+            setPublishPom(true)
+            setPublishIvy(false)
+        }
+    }
+}
\ No newline at end of file
diff --git a/build-logic/src/main/kotlin/geyser.shadow-conventions.gradle.kts b/build-logic/src/main/kotlin/geyser.shadow-conventions.gradle.kts
new file mode 100644
index 000000000..395beb104
--- /dev/null
+++ b/build-logic/src/main/kotlin/geyser.shadow-conventions.gradle.kts
@@ -0,0 +1,32 @@
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+
+plugins {
+    id("geyser.base-conventions")
+    id("com.github.johnrengelman.shadow")
+}
+
+tasks {
+    named<Jar>("jar") {
+        archiveClassifier.set("unshaded")
+        from(project.rootProject.file("LICENSE"))
+    }
+    val shadowJar = named<ShadowJar>("shadowJar") {
+        archiveBaseName.set(project.name)
+        archiveVersion.set("")
+        archiveClassifier.set("")
+
+        val sJar: ShadowJar = this
+
+        doFirst {
+            providedDependencies[project.name]?.forEach { string ->
+                sJar.dependencies {
+                    println("Excluding $string from ${project.name}")
+                    exclude(dependency(string))
+                }
+            }
+        }
+    }
+    named("build") {
+        dependsOn(shadowJar)
+    }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 000000000..7371978d3
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+    `java-library`
+    id("geyser.build-logic")
+    id("io.freefair.lombok") version "6.3.0" apply false
+}
+
+allprojects {
+    group = "org.geysermc"
+    version = "2.1.0-SNAPSHOT"
+    description = "Allows for players from Minecraft: Bedrock Edition to join Minecraft: Java Edition servers."
+
+    tasks.withType<JavaCompile> {
+        options.encoding = "UTF-8"
+    }
+}
+
+val platforms = setOf(
+    projects.bungeecord,
+    projects.spigot,
+    projects.sponge,
+    projects.standalone,
+    projects.velocity
+).map { it.dependencyProject }
+
+val api: Project = projects.api.dependencyProject
+
+subprojects {
+    apply {
+        plugin("java-library")
+        plugin("io.freefair.lombok")
+        plugin("geyser.build-logic")
+    }
+
+    val relativePath = projectDir.relativeTo(rootProject.projectDir).path
+
+    if (relativePath.contains("api")) {
+        plugins.apply("geyser.api-conventions")
+    } else {
+        group = rootProject.group as String + ".geyser"
+        when (this) {
+            in platforms -> plugins.apply("geyser.platform-conventions")
+            api -> plugins.apply("geyser.publish-conventions")
+            else -> plugins.apply("geyser.base-conventions")
+        }
+    }
+}
\ No newline at end of file
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
new file mode 100644
index 000000000..6c1414105
--- /dev/null
+++ b/common/build.gradle.kts
@@ -0,0 +1,4 @@
+dependencies {
+    api("org.geysermc.cumulus", "cumulus", Versions.cumulusVersion)
+    api("com.google.code.gson", "gson", Versions.gsonVersion)
+}
\ No newline at end of file
diff --git a/common/pom.xml b/common/pom.xml
deleted file mode 100644
index 67b77a98a..000000000
--- a/common/pom.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>geyser-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>common</artifactId>
-
-    <!-- Floodgate is still targeting Java 8 -->
-    <properties>
-        <maven.compiler.source>8</maven.compiler.source>
-        <maven.compiler.target>8</maven.compiler.target>
-    </properties>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc.cumulus</groupId>
-            <artifactId>cumulus</artifactId>
-            <version>1.1.1</version>
-        </dependency>
-        <dependency>
-            <groupId>com.google.code.gson</groupId>
-            <artifactId>gson</artifactId>
-            <version>2.8.9</version>
-        </dependency>
-    </dependencies>
-</project>
\ No newline at end of file
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 000000000..49ce2fbff
--- /dev/null
+++ b/core/build.gradle.kts
@@ -0,0 +1,142 @@
+import net.kyori.blossom.BlossomExtension
+
+plugins {
+    id("net.kyori.blossom")
+    id("net.kyori.indra.git")
+    id("geyser.publish-conventions")
+}
+
+dependencies {
+    api(projects.geyserApi)
+    api(projects.common)
+
+    // Jackson JSON and YAML serialization
+    api("com.fasterxml.jackson.core", "jackson-annotations", Versions.jacksonVersion)
+    api("com.fasterxml.jackson.core", "jackson-databind", Versions.jacksonVersion + ".1") // Extra .1 as databind is a slightly different version
+    api("com.fasterxml.jackson.dataformat", "jackson-dataformat-yaml", Versions.jacksonVersion)
+    api("com.google.guava", "guava", Versions.guavaVersion)
+
+    api("com.nukkitx", "nbt", Versions.nbtVersion)
+
+    // Fastutil Maps
+    implementation("com.nukkitx.fastutil", "fastutil-int-int-maps", Versions.fastutilVersion)
+    implementation("com.nukkitx.fastutil", "fastutil-int-long-maps", Versions.fastutilVersion)
+    implementation("com.nukkitx.fastutil", "fastutil-int-byte-maps", Versions.fastutilVersion)
+    implementation("com.nukkitx.fastutil", "fastutil-int-boolean-maps", Versions.fastutilVersion)
+    implementation("com.nukkitx.fastutil", "fastutil-object-int-maps", Versions.fastutilVersion)
+    implementation("com.nukkitx.fastutil", "fastutil-object-object-maps", Versions.fastutilVersion)
+
+    // Network libraries
+    implementation("org.java-websocket", "Java-WebSocket", Versions.websocketVersion)
+
+    api("com.github.CloudburstMC.Protocol", "bedrock-v554", Versions.protocolVersion) {
+        exclude("com.nukkitx.network", "raknet")
+        exclude("com.nukkitx", "nbt")
+    }
+
+    api("com.github.GeyserMC", "MCAuthLib", Versions.mcauthlibVersion)
+    api("com.github.GeyserMC", "MCProtocolLib", Versions.mcprotocollibversion) {
+        exclude("com.github.GeyserMC", "packetlib")
+        exclude("com.github.GeyserMC", "mcauthlib")
+    }
+
+    api("com.github.steveice10", "packetlib", Versions.packetlibVersion) {
+        exclude("io.netty", "netty-all")
+    }
+
+    implementation("com.nukkitx.network", "raknet", Versions.raknetVersion) {
+        exclude("io.netty", "*");
+    }
+
+    implementation("io.netty", "netty-resolver-dns", Versions.nettyVersion)
+    implementation("io.netty", "netty-resolver-dns-native-macos", Versions.nettyVersion, null, "osx-x86_64")
+    implementation("io.netty", "netty-codec-haproxy", Versions.nettyVersion)
+
+    // Network dependencies we are updating ourselves
+    api("io.netty", "netty-handler", Versions.nettyVersion)
+
+    implementation("io.netty", "netty-transport-native-epoll", Versions.nettyVersion, null, "linux-x86_64")
+    implementation("io.netty", "netty-transport-native-epoll", Versions.nettyVersion, null, "linux-aarch_64")
+    implementation("io.netty", "netty-transport-native-kqueue", Versions.nettyVersion, null, "osx-x86_64")
+
+    // Adventure text serialization
+    implementation("net.kyori", "adventure-text-serializer-gson", Versions.adventureVersion) // Remove when we remove our Adventure bump
+    implementation("net.kyori", "adventure-text-serializer-legacy", Versions.adventureVersion)
+    implementation("net.kyori", "adventure-text-serializer-plain", Versions.adventureVersion)
+
+    // Test
+    testImplementation("junit", "junit", Versions.junitVersion)
+
+    // Annotation Processors
+    compileOnly(projects.ap)
+
+    annotationProcessor(projects.ap)
+}
+
+configurations.api {
+    // This is still experimental - additionally, it could only really benefit standalone
+    exclude(group = "io.netty.incubator", module = "netty-incubator-transport-native-io_uring")
+}
+
+tasks.processResources {
+    // This is solely for backwards compatibility for other programs that used this file before the switch to gradle.
+    // It used to be generated by the maven Git-Commit-Id-Plugin
+    filesMatching("git.properties") {
+        val info = GitInfo()
+        expand(
+            "branch" to info.branch,
+            "buildNumber" to info.buildNumber,
+            "projectVersion" to project.version,
+            "commit" to info.commit,
+            "commitAbbrev" to info.commitAbbrev,
+            "commitMessage" to info.commitMessage,
+            "repository" to info.repository
+        )
+    }
+}
+
+configure<BlossomExtension> {
+    val mainFile = "src/main/java/org/geysermc/geyser/GeyserImpl.java"
+    val info = GitInfo()
+
+    replaceToken("\${version}", "${project.version} (${info.gitVersion})", mainFile)
+    replaceToken("\${gitVersion}", info.gitVersion, mainFile)
+    replaceToken("\${buildNumber}", info.buildNumber, mainFile)
+    replaceToken("\${branch}", info.branch, mainFile)
+    replaceToken("\${commit}", info.commit, mainFile)
+    replaceToken("\${repository}", info.repository, mainFile)
+}
+
+fun Project.buildNumber(): Int =
+    System.getenv("BUILD_NUMBER")?.let { Integer.parseInt(it) } ?: -1
+
+inner class GitInfo {
+    val branch: String
+    val commit: String
+    val commitAbbrev: String
+
+    val gitVersion: String
+    val version: String
+    val buildNumber: Int
+
+    val commitMessage: String
+    val repository: String
+
+    init {
+        // On Jenkins, a detached head is checked out, so indra cannot determine the branch.
+        // Fortunately, this environment variable is available.
+        branch = indraGit.branchName() ?: System.getenv("BRANCH_NAME") ?: "DEV"
+
+        val commit = indraGit.commit()
+        this.commit = commit?.name ?: "0".repeat(40)
+        commitAbbrev = commit?.name?.substring(0, 7) ?: "0".repeat(7)
+
+        gitVersion = "git-${branch}-${commitAbbrev}"
+        version = "${project.version} ($gitVersion)"
+        buildNumber = buildNumber()
+
+        val git = indraGit.git()
+        commitMessage = git?.commit()?.message ?: ""
+        repository = git?.repository?.config?.getString("remote", "origin", "url") ?: ""
+    }
+}
diff --git a/core/pom.xml b/core/pom.xml
deleted file mode 100644
index ca6d4d370..000000000
--- a/core/pom.xml
+++ /dev/null
@@ -1,394 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <parent>
-        <groupId>org.geysermc</groupId>
-        <artifactId>geyser-parent</artifactId>
-        <version>2.0.7-SNAPSHOT</version>
-    </parent>
-    <artifactId>core</artifactId>
-
-    <properties>
-        <adventure.version>4.12.0-20220629.025215-9</adventure.version> <!-- Snapshot fixes https://github.com/KyoriPowered/adventure/issues/792 -->
-        <fastutil.version>8.5.2</fastutil.version>
-        <jackson.version>2.13.2</jackson.version>
-        <netty.version>4.1.80.Final</netty.version>
-    </properties>
-
-    <repositories>
-        <repository>
-            <!-- For Adventure snapshots -->
-            <id>sonatype-s01</id>
-            <url>https://s01.oss.sonatype.org/content/repositories/snapshots/</url>
-        </repository>
-    </repositories>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>ap</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>geyser-api</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.geysermc</groupId>
-            <artifactId>common</artifactId>
-            <version>2.0.7-SNAPSHOT</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- Jackson JSON and YAML serialization -->
-        <dependency>
-            <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-annotations</artifactId>
-            <version>${jackson.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-core</artifactId>
-            <version>${jackson.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.fasterxml.jackson.core</groupId>
-            <artifactId>jackson-databind</artifactId>
-            <version>${jackson.version}.1</version> <!-- Extra .1 as databind is a slightly different version -->
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.fasterxml.jackson.dataformat</groupId>
-            <artifactId>jackson-dataformat-yaml</artifactId>
-            <version>${jackson.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.google.guava</groupId>
-            <artifactId>guava</artifactId>
-            <version>29.0-jre</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- fastutil maps -->
-        <dependency>
-            <groupId>com.nukkitx</groupId>
-            <artifactId>nbt</artifactId>
-            <!-- Used for key/value interning -->
-            <version>2.2.1</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.fastutil</groupId>
-            <artifactId>fastutil-int-int-maps</artifactId>
-            <version>${fastutil.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.fastutil</groupId>
-            <artifactId>fastutil-int-long-maps</artifactId>
-            <version>${fastutil.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.fastutil</groupId>
-            <artifactId>fastutil-int-byte-maps</artifactId>
-            <version>${fastutil.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.fastutil</groupId>
-            <artifactId>fastutil-int-boolean-maps</artifactId>
-            <version>${fastutil.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.fastutil</groupId>
-            <artifactId>fastutil-object-int-maps</artifactId>
-            <version>${fastutil.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.fastutil</groupId>
-            <artifactId>fastutil-object-object-maps</artifactId>
-            <version>${fastutil.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- Network libraries -->
-        <dependency>
-            <groupId>org.java-websocket</groupId>
-            <artifactId>Java-WebSocket</artifactId>
-            <version>1.5.1</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.github.CloudburstMC.Protocol</groupId>
-            <artifactId>bedrock-v544</artifactId>
-            <version>0bd459f</version>
-            <scope>compile</scope>
-            <exclusions>
-                <exclusion>
-                    <groupId>com.nukkitx.network</groupId>
-                    <artifactId>raknet</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>com.nukkitx</groupId>
-                    <artifactId>nbt</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>com.nukkitx.network</groupId>
-            <artifactId>raknet</artifactId>
-            <version>1.6.28-20220125.214016-6</version>
-            <scope>compile</scope>
-            <exclusions>
-                <exclusion>
-                    <groupId>io.netty</groupId>
-                    <artifactId>*</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>com.github.GeyserMC</groupId>
-            <artifactId>MCAuthLib</artifactId>
-            <version>d9d773e</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>com.github.GeyserMC</groupId>
-            <artifactId>MCProtocolLib</artifactId>
-            <version>9f78bd5</version>
-            <scope>compile</scope>
-            <exclusions>
-                <exclusion>
-                    <groupId>com.github.GeyserMC</groupId>
-                    <artifactId>packetlib</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>com.github.GeyserMC</groupId>
-                    <artifactId>mcauthlib</artifactId>
-                </exclusion>
-                <exclusion>
-                    <!-- Remove when MCProtocolLib updates to 4.12-SNAPSHOT or later -->
-                    <groupId>net.kyori</groupId>
-                    <artifactId>*</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>com.github.steveice10</groupId>
-            <artifactId>packetlib</artifactId>
-            <version>3.0</version>
-            <scope>compile</scope>
-            <exclusions>
-                <exclusion>
-                    <groupId>io.netty</groupId>
-                    <artifactId>netty-all</artifactId>
-                </exclusion>
-                <exclusion>
-                    <!-- This is still experimental - additionally, it could only really benefit standalone -->
-                    <groupId>io.netty.incubator</groupId>
-                    <artifactId>netty-incubator-transport-native-io_uring</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-resolver-dns</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-resolver-dns-native-macos</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-            <classifier>osx-x86_64</classifier>
-        </dependency>
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-codec-haproxy</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- Network dependencies we are updating ourselves -->
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-handler</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-transport-native-epoll</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-            <classifier>linux-x86_64</classifier>
-        </dependency>
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-transport-native-epoll</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-            <classifier>linux-aarch_64</classifier>
-        </dependency>
-        <dependency>
-            <groupId>io.netty</groupId>
-            <artifactId>netty-transport-native-kqueue</artifactId>
-            <version>${netty.version}</version>
-            <scope>compile</scope>
-            <classifier>osx-x86_64</classifier>
-        </dependency>
-        <!-- Adventure text serialization -->
-        <dependency>
-            <groupId>net.kyori</groupId>
-            <artifactId>adventure-text-serializer-legacy</artifactId>
-            <version>${adventure.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.kyori</groupId>
-            <artifactId>adventure-text-serializer-plain</artifactId>
-            <version>${adventure.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- Remove the following two when MCProtocolLib updates to 4.12-SNAPSHOT or later -->
-        <dependency>
-            <groupId>net.kyori</groupId>
-            <artifactId>adventure-text-serializer-gson</artifactId>
-            <version>${adventure.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <dependency>
-            <groupId>net.kyori</groupId>
-            <artifactId>adventure-text-serializer-gson-legacy-impl</artifactId>
-            <version>${adventure.version}</version>
-            <scope>compile</scope>
-        </dependency>
-        <!-- Other -->
-        <dependency>
-            <groupId>junit</groupId>
-            <artifactId>junit</artifactId>
-            <version>4.13.1</version>
-            <scope>test</scope>
-        </dependency>
-    </dependencies>
-
-    <build>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-                <version>3.2.0</version>
-                <configuration>
-                    <excludes>
-                        <exclude>**/services/javax.annotation.processing.Processor</exclude>
-                    </excludes>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>pl.project13.maven</groupId>
-                <artifactId>git-commit-id-plugin</artifactId>
-                <version>4.0.0</version>
-                <executions>
-                    <execution>
-                        <id>get-the-git-infos</id>
-                        <goals>
-                            <goal>revision</goal>
-                        </goals>
-                    </execution>
-                </executions>
-                <configuration>
-                    <generateGitPropertiesFile>true</generateGitPropertiesFile>
-                    <generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties</generateGitPropertiesFilename>
-                    <format>properties</format>
-                    <failOnNoGitDirectory>false</failOnNoGitDirectory>
-                    <failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo>
-                    <runOnlyOnce>false</runOnlyOnce>
-                    <verbose>true</verbose>
-                    <skipPoms>false</skipPoms>
-                    <excludeProperties>
-                        <excludeProperty>git.user.*</excludeProperty>
-                        <excludeProperty>git.*.user.*</excludeProperty>
-                        <excludeProperty>git.closest.*</excludeProperty>
-                        <excludeProperty>git.commit.id.describe</excludeProperty>
-                        <excludeProperty>git.commit.id.describe-short</excludeProperty>
-                        <excludeProperty>git.commit.message.short</excludeProperty>
-                    </excludeProperties>
-                    <commitIdGenerationMode>flat</commitIdGenerationMode>
-                    <gitDescribe>
-                        <always>true</always>
-                    </gitDescribe>
-                </configuration>
-            </plugin>
-            <plugin>
-                <groupId>com.google.code.maven-replacer-plugin</groupId>
-                <artifactId>replacer</artifactId>
-                <version>1.5.3</version>
-                <executions>
-                    <execution>
-                        <id>add-version</id>
-                        <phase>process-sources</phase>
-                        <goals>
-                            <goal>replace</goal>
-                        </goals>
-                        <configuration>
-                            <includes>
-                                <include>${project.basedir}/src/main/java/org/geysermc/geyser/GeyserImpl.java</include>
-                            </includes>
-                            <replacements>
-                                <replacement>
-                                    <token>String VERSION = ".*"</token>
-                                    <value>String VERSION = "${project.version} (" + GIT_VERSION + ")"</value>
-                                </replacement>
-                                <replacement>
-                                    <token>String GIT_VERSION = ".*"</token>
-                                    <!--suppress UnresolvedMavenProperty -->
-                                    <value>String GIT_VERSION = "git-${git.branch}-${git.commit.id.abbrev}"</value>
-                                </replacement>
-                            </replacements>
-                        </configuration>
-                    </execution>
-
-                    <execution>
-                        <id>remove-version</id>
-                        <phase>process-classes</phase>
-                        <goals>
-                            <goal>replace</goal>
-                        </goals>
-                        <configuration>
-                            <includes>
-                                <include>${project.basedir}/src/main/java/org/geysermc/geyser/GeyserImpl.java</include>
-                            </includes>
-                            <replacements>
-                                <replacement>
-                                    <token>String VERSION = ".*"</token>
-                                    <value>String VERSION = "DEV"</value>
-                                </replacement>
-                                <replacement>
-                                    <token>String GIT_VERSION = ".*"</token>
-                                    <value>String GIT_VERSION = "DEV"</value>
-                                </replacement>
-                            </replacements>
-                        </configuration>
-                    </execution>
-                </executions>
-            </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/core/src/main/java/org/geysermc/connector/GeyserConnector.java b/core/src/main/java/org/geysermc/connector/GeyserConnector.java
index b3307a134..bd14ebb25 100644
--- a/core/src/main/java/org/geysermc/connector/GeyserConnector.java
+++ b/core/src/main/java/org/geysermc/connector/GeyserConnector.java
@@ -26,10 +26,10 @@
 package org.geysermc.connector;
 
 import com.nukkitx.protocol.bedrock.BedrockServer;
+import org.geysermc.api.Geyser;
 import org.geysermc.common.PlatformType;
 import org.geysermc.connector.network.session.GeyserSession;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.api.Geyser;
 
 import java.util.UUID;
 
@@ -91,6 +91,6 @@ public class GeyserConnector {
     }
 
     public boolean isProductionEnvironment() {
-        return GeyserImpl.getInstance().productionEnvironment();
+        return GeyserImpl.getInstance().isProductionEnvironment();
     }
 }
diff --git a/core/src/main/java/org/geysermc/connector/network/session/GeyserSession.java b/core/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
index 890290a01..258787e78 100644
--- a/core/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
+++ b/core/src/main/java/org/geysermc/connector/network/session/GeyserSession.java
@@ -56,11 +56,11 @@ public class GeyserSession {
     }
 
     public String getRemoteAddress() {
-        return this.handle.getRemoteAddress();
+        return this.handle.remoteServer().address();
     }
 
     public int getRemotePort() {
-        return this.handle.getRemotePort();
+        return this.handle.remoteServer().port();
     }
 
     public int getRenderDistance() {
@@ -128,7 +128,7 @@ public class GeyserSession {
     }
 
     public String getName() {
-        return this.handle.name();
+        return this.handle.bedrockUsername();
     }
 
     public boolean isConsole() {
@@ -136,7 +136,7 @@ public class GeyserSession {
     }
 
     public String getLocale() {
-        return this.handle.getLocale();
+        return this.handle.locale();
     }
 
     public void sendUpstreamPacket(BedrockPacket packet) {
diff --git a/core/src/main/java/org/geysermc/connector/network/session/auth/AuthData.java b/core/src/main/java/org/geysermc/connector/network/session/auth/AuthData.java
index 6b8e53d8e..cca7aa48c 100644
--- a/core/src/main/java/org/geysermc/connector/network/session/auth/AuthData.java
+++ b/core/src/main/java/org/geysermc/connector/network/session/auth/AuthData.java
@@ -33,6 +33,7 @@ import java.util.UUID;
  *
  * @deprecated legacy code
  */
+@Deprecated
 public class AuthData {
     private final org.geysermc.geyser.session.auth.AuthData handle;
 
diff --git a/core/src/main/java/org/geysermc/geyser/FloodgateKeyLoader.java b/core/src/main/java/org/geysermc/geyser/FloodgateKeyLoader.java
index 0aa1d39c3..8b51228c8 100644
--- a/core/src/main/java/org/geysermc/geyser/FloodgateKeyLoader.java
+++ b/core/src/main/java/org/geysermc/geyser/FloodgateKeyLoader.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser;
 
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.configuration.GeyserJacksonConfiguration;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.nio.file.Files;
@@ -34,7 +34,7 @@ import java.nio.file.Path;
 
 public class FloodgateKeyLoader {
     public static Path getKeyPath(GeyserJacksonConfiguration config, Path floodgateDataFolder, Path geyserDataFolder, GeyserLogger logger) {
-        if (config.getRemote().getAuthType() != AuthType.FLOODGATE) {
+        if (config.getRemote().authType() != AuthType.FLOODGATE) {
             return geyserDataFolder.resolve(config.getFloodgateKeyFile());
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java b/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java
index d40060310..261c7416b 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserBootstrap.java
@@ -25,7 +25,7 @@
 
 package org.geysermc.geyser;
 
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.dump.BootstrapDumpInfo;
 import org.geysermc.geyser.level.GeyserWorldManager;
@@ -72,7 +72,7 @@ public interface GeyserBootstrap {
      *
      * @return The current CommandManager
      */
-    CommandManager getGeyserCommandManager();
+    GeyserCommandManager getGeyserCommandManager();
 
     /**
      * Returns the current PingPassthrough manager
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
index d9f4d8a15..a10e54f90 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
@@ -43,19 +43,32 @@ import lombok.Getter;
 import lombok.Setter;
 import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.format.NamedTextColor;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.geysermc.api.Geyser;
 import org.geysermc.common.PlatformType;
+import org.geysermc.cumulus.form.Form;
+import org.geysermc.cumulus.form.util.FormBuilder;
 import org.geysermc.floodgate.crypto.AesCipher;
 import org.geysermc.floodgate.crypto.AesKeyProducer;
 import org.geysermc.floodgate.crypto.Base64Topping;
 import org.geysermc.floodgate.crypto.FloodgateCipher;
 import org.geysermc.floodgate.news.NewsItemAction;
 import org.geysermc.geyser.api.GeyserApi;
-import org.geysermc.geyser.command.CommandManager;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.event.lifecycle.GeyserPostInitializeEvent;
+import org.geysermc.geyser.api.event.lifecycle.GeyserPreInitializeEvent;
+import org.geysermc.geyser.api.event.lifecycle.GeyserShutdownEvent;
+import org.geysermc.geyser.api.network.AuthType;
+import org.geysermc.geyser.api.network.BedrockListener;
+import org.geysermc.geyser.api.network.RemoteServer;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.entity.EntityDefinitions;
+import org.geysermc.geyser.event.GeyserEventBus;
+import org.geysermc.geyser.extension.GeyserExtensionManager;
 import org.geysermc.geyser.level.WorldManager;
 import org.geysermc.geyser.network.ConnectorServerEventHandler;
 import org.geysermc.geyser.pack.ResourcePack;
@@ -65,7 +78,6 @@ import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.PendingMicrosoftAuthentication;
 import org.geysermc.geyser.session.SessionManager;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.skin.FloodgateSkinUploader;
 import org.geysermc.geyser.skin.SkinProvider;
 import org.geysermc.geyser.text.GeyserLocale;
@@ -77,7 +89,6 @@ import org.geysermc.geyser.util.*;
 import java.io.File;
 import java.io.FileWriter;
 import java.io.IOException;
-import java.io.InputStream;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.UnknownHostException;
@@ -100,8 +111,13 @@ public class GeyserImpl implements GeyserApi {
             .enable(JsonParser.Feature.ALLOW_SINGLE_QUOTES);
 
     public static final String NAME = "Geyser";
-    public static final String GIT_VERSION = "DEV"; // A fallback for running in IDEs
-    public static final String VERSION = "DEV"; // A fallback for running in IDEs
+    public static final String GIT_VERSION = "${gitVersion}"; // A fallback for running in IDEs
+    public static final String VERSION = "${version}"; // A fallback for running in IDEs
+
+    public static final String BUILD_NUMBER = "${buildNumber}";
+    public static final String BRANCH = "${branch}";
+    public static final String COMMIT = "${commit}";
+    public static final String REPOSITORY = "${repository}";
 
     /**
      * Oauth client ID for Microsoft authentication
@@ -130,6 +146,9 @@ public class GeyserImpl implements GeyserApi {
     private final PlatformType platformType;
     private final GeyserBootstrap bootstrap;
 
+    private final EventBus<EventRegistrar> eventBus;
+    private final GeyserExtensionManager extensionManager;
+
     private Metrics metrics;
 
     private PendingMicrosoftAuthentication pendingMicrosoftAuthentication;
@@ -146,9 +165,20 @@ public class GeyserImpl implements GeyserApi {
         this.platformType = platformType;
         this.bootstrap = bootstrap;
 
+        GeyserLocale.finalizeDefaultLocale(this);
+
+        /* Initialize event bus */
+        this.eventBus = new GeyserEventBus();
+
+        /* Load Extensions */
+        this.extensionManager = new GeyserExtensionManager();
+        this.extensionManager.init();
+        this.eventBus.fire(new GeyserPreInitializeEvent(this.extensionManager, this.eventBus));
+    }
+
+    public void initialize() {
         long startupTime = System.currentTimeMillis();
 
-        GeyserLocale.finalizeDefaultLocale(this);
         GeyserLogger logger = bootstrap.getGeyserLogger();
 
         logger.info("******************************************");
@@ -157,16 +187,17 @@ public class GeyserImpl implements GeyserApi {
         logger.info("");
         logger.info("******************************************");
 
-        /* Initialize translators and registries */
-        BlockRegistries.init();
+        /* Initialize registries */
         Registries.init();
+        BlockRegistries.init();
 
+        /* Initialize translators */
         EntityDefinitions.init();
         ItemTranslator.init();
         MessageTranslator.init();
         MinecraftLocale.init();
 
-        start();
+        startInstance();
 
         GeyserConfiguration config = bootstrap.getGeyserConfig();
 
@@ -193,12 +224,12 @@ public class GeyserImpl implements GeyserApi {
 
         if (platformType == PlatformType.STANDALONE) {
             logger.warning(GeyserLocale.getLocaleStringLog("geyser.core.movement_warn"));
-        } else if (config.getRemote().getAuthType() == AuthType.FLOODGATE) {
+        } else if (config.getRemote().authType() == AuthType.FLOODGATE) {
             VersionCheckUtils.checkForOutdatedFloodgate(logger);
         }
     }
 
-    private void start() {
+    private void startInstance() {
         this.scheduledThread = Executors.newSingleThreadScheduledExecutor(new DefaultThreadFactory("Geyser Scheduled Thread"));
 
         GeyserLogger logger = bootstrap.getGeyserLogger();
@@ -210,7 +241,7 @@ public class GeyserImpl implements GeyserApi {
 
         ResourcePack.loadPacks();
 
-        if (platformType != PlatformType.STANDALONE && config.getRemote().getAddress().equals("auto")) {
+        if (platformType != PlatformType.STANDALONE && config.getRemote().address().equals("auto")) {
             // Set the remote address to localhost since that is where we are always connecting
             try {
                 config.getRemote().setAddress(InetAddress.getLocalHost().getHostAddress());
@@ -222,7 +253,7 @@ public class GeyserImpl implements GeyserApi {
                 config.getRemote().setAddress(InetAddress.getLoopbackAddress().getHostAddress());
             }
         }
-        String remoteAddress = config.getRemote().getAddress();
+        String remoteAddress = config.getRemote().address();
         // Filters whether it is not an IP address or localhost, because otherwise it is not possible to find out an SRV entry.
         if (!remoteAddress.matches(IP_REGEX) && !remoteAddress.equalsIgnoreCase("localhost")) {
             String[] record = WebUtils.findSrvRecord(this, remoteAddress);
@@ -237,27 +268,9 @@ public class GeyserImpl implements GeyserApi {
         // Ensure that PacketLib does not create an event loop for handling packets; we'll do that ourselves
         TcpSession.USE_EVENT_LOOP_FOR_PACKETS = false;
 
-        String branch = "unknown";
-        int buildNumber = -1;
-        if (this.productionEnvironment()) {
-            try (InputStream stream = bootstrap.getResource("git.properties")) {
-                Properties gitProperties = new Properties();
-                gitProperties.load(stream);
-                branch = gitProperties.getProperty("git.branch");
-                String build = gitProperties.getProperty("git.build.number");
-                if (build != null) {
-                    buildNumber = Integer.parseInt(build);
-                }
-            } catch (Throwable e) {
-                logger.error("Failed to read git.properties", e);
-            }
-        } else {
-            logger.debug("Not getting git properties for the news handler as we are in a development environment.");
-        }
-
         pendingMicrosoftAuthentication = new PendingMicrosoftAuthentication(config.getPendingAuthenticationTimeout());
 
-        this.newsHandler = new NewsHandler(branch, buildNumber);
+        this.newsHandler = new NewsHandler(BRANCH, this.buildNumber());
 
         CooldownUtils.setDefaultShowCooldown(config.getShowCooldown());
         DimensionUtils.changeBedrockNetherId(config.isAboveBedrockNetherBuilding()); // Apply End dimension ID workaround to Nether
@@ -274,7 +287,7 @@ public class GeyserImpl implements GeyserApi {
 
         boolean enableProxyProtocol = config.getBedrock().isEnableProxyProtocol();
         bedrockServer = new BedrockServer(
-                new InetSocketAddress(config.getBedrock().getAddress(), config.getBedrock().getPort()),
+                new InetSocketAddress(config.getBedrock().address(), config.getBedrock().port()),
                 bedrockThreadCount,
                 EventLoops.commonGroup(),
                 enableProxyProtocol
@@ -297,11 +310,11 @@ public class GeyserImpl implements GeyserApi {
         if (shouldStartListener) {
             bedrockServer.bind().whenComplete((avoid, throwable) -> {
                 if (throwable == null) {
-                    logger.info(GeyserLocale.getLocaleStringLog("geyser.core.start", config.getBedrock().getAddress(),
-                            String.valueOf(config.getBedrock().getPort())));
+                    logger.info(GeyserLocale.getLocaleStringLog("geyser.core.start", config.getBedrock().address(),
+                            String.valueOf(config.getBedrock().port())));
                 } else {
-                    String address = config.getBedrock().getAddress();
-                    int port = config.getBedrock().getPort();
+                    String address = config.getBedrock().address();
+                    int port = config.getBedrock().port();
                     logger.severe(GeyserLocale.getLocaleStringLog("geyser.core.fail", address, String.valueOf(port)));
                     if (!"0.0.0.0".equals(address)) {
                         logger.info(Component.text("Suggestion: try setting `address` under `bedrock` in the Geyser config back to 0.0.0.0", NamedTextColor.GREEN));
@@ -311,7 +324,7 @@ public class GeyserImpl implements GeyserApi {
             }).join();
         }
 
-        if (config.getRemote().getAuthType() == AuthType.FLOODGATE) {
+        if (config.getRemote().authType() == AuthType.FLOODGATE) {
             try {
                 Key key = new AesKeyProducer().produceFrom(config.getFloodgateKeyPath());
                 cipher = new AesCipher(new Base64Topping());
@@ -329,7 +342,7 @@ public class GeyserImpl implements GeyserApi {
             metrics = new Metrics(this, "GeyserMC", config.getMetrics().getUniqueId(), false, java.util.logging.Logger.getLogger(""));
             metrics.addCustomChart(new Metrics.SingleLineChart("players", sessionManager::size));
             // Prevent unwanted words best we can
-            metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase(Locale.ROOT)));
+            metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().authType().toString().toLowerCase(Locale.ROOT)));
             metrics.addCustomChart(new Metrics.SimplePie("platform", platformType::getPlatformName));
             metrics.addCustomChart(new Metrics.SimplePie("defaultLocale", GeyserLocale::getDefaultLocale));
             metrics.addCustomChart(new Metrics.SimplePie("version", () -> GeyserImpl.VERSION));
@@ -413,7 +426,7 @@ public class GeyserImpl implements GeyserApi {
             metrics = null;
         }
 
-        if (config.getRemote().getAuthType() == AuthType.ONLINE) {
+        if (config.getRemote().authType() == AuthType.ONLINE) {
             if (config.getUserAuths() != null && !config.getUserAuths().isEmpty()) {
                 getLogger().warning("The 'userAuths' config section is now deprecated, and will be removed in the near future! " +
                         "Please migrate to the new 'saved-user-logins' config option: " +
@@ -455,25 +468,26 @@ public class GeyserImpl implements GeyserApi {
         }
 
         newsHandler.handleNews(null, NewsItemAction.ON_SERVER_STARTED);
+
+        this.eventBus.fire(new GeyserPostInitializeEvent(this.extensionManager, this.eventBus));
         if (config.isNotifyOnNewBedrockUpdate()) {
             VersionCheckUtils.checkForGeyserUpdate(this::getLogger);
         }
     }
 
     @Override
-    public @Nullable GeyserSession connectionByName(@NonNull String name) {
-        for (GeyserSession session : sessionManager.getAllSessions()) {
-            if (session.name().equals(name) || session.getProtocol().getProfile().getName().equals(name)) {
-                return session;
-            }
-        }
-
-        return null;
+    public @NonNull List<GeyserSession> onlineConnections() {
+        return sessionManager.getAllSessions();
     }
 
     @Override
-    public @NonNull List<GeyserSession> onlineConnections() {
-        return this.sessionManager.getAllSessions();
+    public int onlineConnectionsCount() {
+        return sessionManager.size();
+    }
+
+    @Override
+    public @MonotonicNonNull String usernamePrefix() {
+        return null;
     }
 
     @Override
@@ -483,16 +497,40 @@ public class GeyserImpl implements GeyserApi {
 
     @Override
     public @Nullable GeyserSession connectionByXuid(@NonNull String xuid) {
-        for (GeyserSession session : sessionManager.getAllSessions()) {
-            if (session.xuid().equals(xuid)) {
-                return session;
-            }
-        }
-
-        return null;
+        return sessionManager.sessionByXuid(xuid);
     }
 
     @Override
+    public boolean isBedrockPlayer(@NonNull UUID uuid) {
+        return connectionByUuid(uuid) != null;
+    }
+
+    @Override
+    public boolean sendForm(@NonNull UUID uuid, @NonNull Form form) {
+        Objects.requireNonNull(uuid);
+        Objects.requireNonNull(form);
+        GeyserSession session = connectionByUuid(uuid);
+        if (session == null) {
+            return false;
+        }
+        return session.sendForm(form);
+    }
+
+    @Override
+    public boolean sendForm(@NonNull UUID uuid, @NonNull FormBuilder<?, ?, ?> formBuilder) {
+        return sendForm(uuid, formBuilder.build());
+    }
+
+    @Override
+    public boolean transfer(@NonNull UUID uuid, @NonNull String address, int port) {
+        Objects.requireNonNull(uuid);
+        GeyserSession session = connectionByUuid(uuid);
+        if (session == null) {
+            return false;
+        }
+        return session.transfer(address, port);
+    }
+
     public void shutdown() {
         bootstrap.getGeyserLogger().info(GeyserLocale.getLocaleStringLog("geyser.core.shutdown"));
         shuttingDown = true;
@@ -509,16 +547,19 @@ public class GeyserImpl implements GeyserApi {
             skinUploader.close();
         }
         newsHandler.shutdown();
-        this.getCommandManager().getCommands().clear();
+        this.commandManager().getCommands().clear();
 
         ResourcePack.PACKS.clear();
 
+        this.eventBus.fire(new GeyserShutdownEvent(this.extensionManager, this.eventBus));
+        this.extensionManager.disableExtensions();
+
         bootstrap.getGeyserLogger().info(GeyserLocale.getLocaleStringLog("geyser.core.shutdown.done"));
     }
 
-    @Override
     public void reload() {
         shutdown();
+        this.extensionManager.enableExtensions();
         bootstrap.onEnable();
     }
 
@@ -528,24 +569,73 @@ public class GeyserImpl implements GeyserApi {
      *
      * @return true if the version number is not 'DEV'.
      */
-    @Override
-    public boolean productionEnvironment() {
-        //noinspection ConstantConditions - changes in production
-        return !"DEV".equals(GeyserImpl.VERSION);
+    public boolean isProductionEnvironment() {
+        // First is if Blossom runs, second is if Blossom doesn't run
+        // noinspection ConstantConditions - changes in production
+        return !("git-local/dev-0000000".equals(GeyserImpl.GIT_VERSION) || "${gitVersion}".equals(GeyserImpl.GIT_VERSION));
     }
 
-    public static GeyserImpl start(PlatformType platformType, GeyserBootstrap bootstrap) {
+    @Override
+    @NonNull
+    public GeyserExtensionManager extensionManager() {
+        return this.extensionManager;
+    }
+
+    @NonNull
+    public GeyserCommandManager commandManager() {
+        return this.bootstrap.getGeyserCommandManager();
+    }
+
+    @Override
+    public <R extends T, T> @NonNull R provider(@NonNull Class<T> apiClass, @Nullable Object... args) {
+        return (R) Registries.PROVIDERS.get(apiClass).create(args);
+    }
+
+    @Override
+    @NonNull
+    public EventBus<EventRegistrar> eventBus() {
+        return this.eventBus;
+    }
+
+    @NonNull
+    public RemoteServer defaultRemoteServer() {
+        return getConfig().getRemote();
+    }
+
+    @Override
+    @NonNull
+    public BedrockListener bedrockListener() {
+        return getConfig().getBedrock();
+    }
+
+    public int buildNumber() {
+        if (!this.isProductionEnvironment()) {
+            return 0;
+        }
+
+        return Integer.parseInt(BUILD_NUMBER);
+    }
+
+    public static GeyserImpl load(PlatformType platformType, GeyserBootstrap bootstrap) {
         if (instance == null) {
             return new GeyserImpl(platformType, bootstrap);
         }
 
+        return instance;
+    }
+
+    public static void start() {
+        if (instance == null) {
+            throw new RuntimeException("Geyser has not been loaded yet!");
+        }
+
         // We've been reloaded
         if (instance.isShuttingDown()) {
             instance.shuttingDown = false;
-            instance.start();
+            instance.startInstance();
+        } else {
+            instance.initialize();
         }
-
-        return instance;
     }
 
     public GeyserLogger getLogger() {
@@ -556,10 +646,6 @@ public class GeyserImpl implements GeyserApi {
         return bootstrap.getGeyserConfig();
     }
 
-    public CommandManager getCommandManager() {
-        return bootstrap.getGeyserCommandManager();
-    }
-
     public WorldManager getWorldManager() {
         return bootstrap.getWorldManager();
     }
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserLogger.java b/core/src/main/java/org/geysermc/geyser/GeyserLogger.java
index 197a031dd..88220eec9 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserLogger.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserLogger.java
@@ -26,11 +26,11 @@
 package org.geysermc.geyser;
 
 import net.kyori.adventure.text.Component;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.command.GeyserCommandSource;
 
 import javax.annotation.Nullable;
 
-public interface GeyserLogger extends CommandSender {
+public interface GeyserLogger extends GeyserCommandSource {
 
     /**
      * Logs a severe message to console
diff --git a/core/src/main/java/org/geysermc/geyser/command/CommandManager.java b/core/src/main/java/org/geysermc/geyser/command/CommandManager.java
deleted file mode 100644
index 38a86fdd0..000000000
--- a/core/src/main/java/org/geysermc/geyser/command/CommandManager.java
+++ /dev/null
@@ -1,123 +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.command;
-
-import lombok.Getter;
-
-import org.geysermc.common.PlatformType;
-import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.defaults.*;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.text.GeyserLocale;
-
-import java.util.*;
-
-public abstract class CommandManager {
-
-    @Getter
-    private final Map<String, GeyserCommand> commands = new HashMap<>();
-
-    private final GeyserImpl geyser;
-
-    public CommandManager(GeyserImpl geyser) {
-        this.geyser = geyser;
-
-        registerCommand(new HelpCommand(geyser, "help", "geyser.commands.help.desc", "geyser.command.help"));
-        registerCommand(new ListCommand(geyser, "list", "geyser.commands.list.desc", "geyser.command.list"));
-        registerCommand(new ReloadCommand(geyser, "reload", "geyser.commands.reload.desc", "geyser.command.reload"));
-        registerCommand(new OffhandCommand(geyser, "offhand", "geyser.commands.offhand.desc", "geyser.command.offhand"));
-        registerCommand(new DumpCommand(geyser, "dump", "geyser.commands.dump.desc", "geyser.command.dump"));
-        registerCommand(new VersionCommand(geyser, "version", "geyser.commands.version.desc", "geyser.command.version"));
-        registerCommand(new SettingsCommand(geyser, "settings", "geyser.commands.settings.desc", "geyser.command.settings"));
-        registerCommand(new StatisticsCommand(geyser, "statistics", "geyser.commands.statistics.desc", "geyser.command.statistics"));
-        registerCommand(new AdvancementsCommand("advancements", "geyser.commands.advancements.desc", "geyser.command.advancements"));
-        registerCommand(new AdvancedTooltipsCommand("tooltips", "geyser.commands.advancedtooltips.desc", "geyser.command.tooltips"));
-        registerCommand(new ConnectionTestCommand(geyser, "connectiontest", "geyser.commands.connectiontest.desc", "geyser.command.connectiontest"));
-        if (GeyserImpl.getInstance().getPlatformType() == PlatformType.STANDALONE) {
-            registerCommand(new StopCommand(geyser, "stop", "geyser.commands.stop.desc", "geyser.command.stop"));
-        }
-    }
-
-    public void registerCommand(GeyserCommand command) {
-        commands.put(command.getName(), command);
-        geyser.getLogger().debug(GeyserLocale.getLocaleStringLog("geyser.commands.registered", command.getName()));
-
-        if (command.getAliases().isEmpty())
-            return;
-
-        for (String alias : command.getAliases())
-            commands.put(alias, command);
-    }
-
-    public void runCommand(CommandSender sender, String command) {
-        if (!command.startsWith("geyser "))
-            return;
-
-        command = command.trim().replace("geyser ", "");
-        String label;
-        String[] args;
-
-        if (!command.contains(" ")) {
-            label = command.toLowerCase();
-            args = new String[0];
-        } else {
-            label = command.substring(0, command.indexOf(" ")).toLowerCase();
-            String argLine = command.substring(command.indexOf(" ") + 1);
-            args = argLine.contains(" ") ? argLine.split(" ") : new String[] { argLine };
-        }
-
-        GeyserCommand cmd = commands.get(label);
-        if (cmd == null) {
-            geyser.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.commands.invalid"));
-            return;
-        }
-
-        if (sender instanceof GeyserSession) {
-            cmd.execute((GeyserSession) sender, sender, args);
-        } else {
-            if (!cmd.isBedrockOnly()) {
-                cmd.execute(null, sender, args);
-            } else {
-                geyser.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.bootstrap.command.bedrock_only"));
-            }
-        }
-    }
-
-    /**
-     * @return a list of all subcommands under {@code /geyser}.
-     */
-    public List<String> getCommandNames() {
-        return Arrays.asList(geyser.getCommandManager().getCommands().keySet().toArray(new String[0]));
-    }
-
-    /**
-     * Returns the description of the given command
-     *
-     * @param command Command to get the description for
-     * @return Command description
-     */
-    public abstract String getDescription(String command);
-}
diff --git a/core/src/main/java/org/geysermc/geyser/command/GeyserCommand.java b/core/src/main/java/org/geysermc/geyser/command/GeyserCommand.java
index a22c69c04..5808dbc2c 100644
--- a/core/src/main/java/org/geysermc/geyser/command/GeyserCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/GeyserCommand.java
@@ -27,17 +27,19 @@ package org.geysermc.geyser.command;
 
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
-import lombok.Setter;
+import lombok.experimental.Accessors;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.command.Command;
 import org.geysermc.geyser.session.GeyserSession;
 
 import javax.annotation.Nullable;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
+@Accessors(fluent = true)
 @Getter
 @RequiredArgsConstructor
-public abstract class GeyserCommand {
+public abstract class GeyserCommand implements Command {
 
     protected final String name;
     /**
@@ -46,16 +48,16 @@ public abstract class GeyserCommand {
     protected final String description;
     protected final String permission;
 
-    @Setter
-    private List<String> aliases = new ArrayList<>();
+    private List<String> aliases = Collections.emptyList();
 
-    public abstract void execute(@Nullable GeyserSession session, CommandSender sender, String[] args);
+    public abstract void execute(@Nullable GeyserSession session, GeyserCommandSource sender, String[] args);
 
     /**
      * If false, hides the command from being shown on the Geyser Standalone GUI.
      *
      * @return true if the command can be run on the server console
      */
+    @Override
     public boolean isExecutableOnConsole() {
         return true;
     }
@@ -65,26 +67,23 @@ public abstract class GeyserCommand {
      *
      * @return a list of all possible subcommands, or empty if none.
      */
-    public List<String> getSubCommands() {
+    @NonNull
+    @Override
+    public List<String> subCommands() {
         return Collections.emptyList();
     }
 
     /**
-     * Shortcut to {@link #getSubCommands()}{@code .isEmpty()}.
+     * Shortcut to {@link #subCommands()} ()}{@code .isEmpty()}.
      *
      * @return true if there are subcommand present for this command.
      */
     public boolean hasSubCommands() {
-        return !getSubCommands().isEmpty();
+        return !this.subCommands().isEmpty();
     }
 
-    /**
-     * Used to send a deny message to Java players if this command can only be used by Bedrock players.
-     *
-     * @return true if this command can only be used by Bedrock players.
-     */
-    public boolean isBedrockOnly() {
-        return false;
+    public void setAliases(List<String> aliases) {
+        this.aliases = aliases;
     }
 
     /**
@@ -92,6 +91,7 @@ public abstract class GeyserCommand {
      *
      * @return if this command is designated to be used only by server operators.
      */
+    @Override
     public boolean isSuggestedOpOnly() {
         return false;
     }
diff --git a/core/src/main/java/org/geysermc/geyser/command/CommandExecutor.java b/core/src/main/java/org/geysermc/geyser/command/GeyserCommandExecutor.java
similarity index 85%
rename from core/src/main/java/org/geysermc/geyser/command/CommandExecutor.java
rename to core/src/main/java/org/geysermc/geyser/command/GeyserCommandExecutor.java
index 5fa5f688b..a9b1c734f 100644
--- a/core/src/main/java/org/geysermc/geyser/command/CommandExecutor.java
+++ b/core/src/main/java/org/geysermc/geyser/command/GeyserCommandExecutor.java
@@ -27,6 +27,7 @@ package org.geysermc.geyser.command;
 
 import lombok.AllArgsConstructor;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.command.Command;
 import org.geysermc.geyser.session.GeyserSession;
 
 import javax.annotation.Nullable;
@@ -36,19 +37,20 @@ import java.util.List;
 import java.util.Map;
 
 /**
- * Represents helper functions for listening to {@code /geyser} commands.
+ * Represents helper functions for listening to {@code /geyser} or {@code /geyserext} commands.
  */
 @AllArgsConstructor
-public class CommandExecutor {
+public class GeyserCommandExecutor {
 
     protected final GeyserImpl geyser;
+    private final Map<String, Command> commands;
 
     public GeyserCommand getCommand(String label) {
-        return geyser.getCommandManager().getCommands().get(label);
+        return (GeyserCommand) commands.get(label);
     }
 
     @Nullable
-    public GeyserSession getGeyserSession(CommandSender sender) {
+    public GeyserSession getGeyserSession(GeyserCommandSource sender) {
         if (sender.isConsole()) {
             return null;
         }
@@ -70,20 +72,18 @@ public class CommandExecutor {
      *               If the command sender does not have the permission for a given command, the command will not be shown.
      * @return A list of command names to include in the tab complete
      */
-    public List<String> tabComplete(CommandSender sender) {
+    public List<String> tabComplete(GeyserCommandSource sender) {
         if (getGeyserSession(sender) != null) {
             // Bedrock doesn't get tab completions or argument suggestions
             return Collections.emptyList();
         }
 
         List<String> availableCommands = new ArrayList<>();
-        Map<String, GeyserCommand> commands = geyser.getCommandManager().getCommands();
 
         // Only show commands they have permission to use
-        for (Map.Entry<String, GeyserCommand> entry : commands.entrySet()) {
-            GeyserCommand geyserCommand = entry.getValue();
-            if (sender.hasPermission(geyserCommand.getPermission())) {
-
+        for (Map.Entry<String, Command> entry : commands.entrySet()) {
+            Command geyserCommand = entry.getValue();
+            if (sender.hasPermission(geyserCommand.permission())) {
                 if (geyserCommand.isBedrockOnly()) {
                     // Don't show commands the JE player can't run
                     continue;
diff --git a/core/src/main/java/org/geysermc/geyser/command/GeyserCommandManager.java b/core/src/main/java/org/geysermc/geyser/command/GeyserCommandManager.java
new file mode 100644
index 000000000..7c5bd6580
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/command/GeyserCommandManager.java
@@ -0,0 +1,328 @@
+/*
+ * 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.command;
+
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.common.PlatformType;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.command.CommandExecutor;
+import org.geysermc.geyser.api.command.CommandSource;
+import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCommandsEvent;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.command.defaults.AdvancedTooltipsCommand;
+import org.geysermc.geyser.command.defaults.AdvancementsCommand;
+import org.geysermc.geyser.command.defaults.ConnectionTestCommand;
+import org.geysermc.geyser.command.defaults.DumpCommand;
+import org.geysermc.geyser.command.defaults.ExtensionsCommand;
+import org.geysermc.geyser.command.defaults.HelpCommand;
+import org.geysermc.geyser.command.defaults.ListCommand;
+import org.geysermc.geyser.command.defaults.OffhandCommand;
+import org.geysermc.geyser.command.defaults.ReloadCommand;
+import org.geysermc.geyser.command.defaults.SettingsCommand;
+import org.geysermc.geyser.command.defaults.StatisticsCommand;
+import org.geysermc.geyser.command.defaults.StopCommand;
+import org.geysermc.geyser.command.defaults.VersionCommand;
+import org.geysermc.geyser.event.type.GeyserDefineCommandsEventImpl;
+import org.geysermc.geyser.extension.command.GeyserExtensionCommand;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.GeyserLocale;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@RequiredArgsConstructor
+public abstract class GeyserCommandManager {
+
+    @Getter
+    private final Map<String, Command> commands = new Object2ObjectOpenHashMap<>(12);
+    private final Map<Extension, Map<String, Command>> extensionCommands = new Object2ObjectOpenHashMap<>(0);
+
+    private final GeyserImpl geyser;
+
+    public void init() {
+        registerBuiltInCommand(new HelpCommand(geyser, "help", "geyser.commands.help.desc", "geyser.command.help", "geyser", this.commands));
+        registerBuiltInCommand(new ListCommand(geyser, "list", "geyser.commands.list.desc", "geyser.command.list"));
+        registerBuiltInCommand(new ReloadCommand(geyser, "reload", "geyser.commands.reload.desc", "geyser.command.reload"));
+        registerBuiltInCommand(new OffhandCommand(geyser, "offhand", "geyser.commands.offhand.desc", "geyser.command.offhand"));
+        registerBuiltInCommand(new DumpCommand(geyser, "dump", "geyser.commands.dump.desc", "geyser.command.dump"));
+        registerBuiltInCommand(new VersionCommand(geyser, "version", "geyser.commands.version.desc", "geyser.command.version"));
+        registerBuiltInCommand(new SettingsCommand(geyser, "settings", "geyser.commands.settings.desc", "geyser.command.settings"));
+        registerBuiltInCommand(new StatisticsCommand(geyser, "statistics", "geyser.commands.statistics.desc", "geyser.command.statistics"));
+        registerBuiltInCommand(new AdvancementsCommand("advancements", "geyser.commands.advancements.desc", "geyser.command.advancements"));
+        registerBuiltInCommand(new AdvancedTooltipsCommand("tooltips", "geyser.commands.advancedtooltips.desc", "geyser.command.tooltips"));
+        registerBuiltInCommand(new ConnectionTestCommand(geyser, "connectiontest", "geyser.commands.connectiontest.desc", "geyser.command.connectiontest"));
+        if (this.geyser.getPlatformType() == PlatformType.STANDALONE) {
+            registerBuiltInCommand(new StopCommand(geyser, "stop", "geyser.commands.stop.desc", "geyser.command.stop"));
+        }
+
+        if (this.geyser.extensionManager().extensions().size() > 0) {
+            registerBuiltInCommand(new ExtensionsCommand(this.geyser, "extensions", "geyser.commands.extensions.desc", "geyser.command.extensions"));
+        }
+
+        GeyserDefineCommandsEvent defineCommandsEvent = new GeyserDefineCommandsEventImpl(this.commands) {
+
+            @Override
+            public void register(@NonNull Command command) {
+                if (!(command instanceof GeyserExtensionCommand extensionCommand)) {
+                    throw new IllegalArgumentException("Expected GeyserExtensionCommand as part of command registration but got " + command + "! Did you use the Command builder properly?");
+                }
+
+                registerExtensionCommand(extensionCommand.extension(), extensionCommand);
+            }
+        };
+
+        this.geyser.eventBus().fire(defineCommandsEvent);
+
+        // Register help commands for all extensions with commands
+        for (Map.Entry<Extension, Map<String, Command>> entry : this.extensionCommands.entrySet()) {
+            registerExtensionCommand(entry.getKey(), new HelpCommand(this.geyser, "help", "geyser.commands.exthelp.desc", "geyser.command.exthelp", entry.getKey().description().id(), entry.getValue()));
+        }
+    }
+
+    /**
+     * For internal Geyser commands
+     */
+    public void registerBuiltInCommand(GeyserCommand command) {
+        register(command, this.commands);
+    }
+
+    public void registerExtensionCommand(@NonNull Extension extension, @NonNull Command command) {
+        register(command, this.extensionCommands.computeIfAbsent(extension, e -> new HashMap<>()));
+    }
+
+    private void register(Command command, Map<String, Command> commands) {
+        commands.put(command.name(), command);
+        geyser.getLogger().debug(GeyserLocale.getLocaleStringLog("geyser.commands.registered", command.name()));
+
+        if (command.aliases().isEmpty()) {
+            return;
+        }
+
+        for (String alias : command.aliases()) {
+            commands.put(alias, command);
+        }
+    }
+
+    @NotNull
+    public Map<String, Command> commands() {
+        return Collections.unmodifiableMap(this.commands);
+    }
+
+    @NotNull
+    public Map<Extension, Map<String, Command>> extensionCommands() {
+        return Collections.unmodifiableMap(this.extensionCommands);
+    }
+
+    public boolean runCommand(GeyserCommandSource sender, String command) {
+        Extension extension = null;
+        for (Extension loopedExtension : this.extensionCommands.keySet()) {
+            if (command.startsWith(loopedExtension.description().id() + " ")) {
+                extension = loopedExtension;
+                break;
+            }
+        }
+
+        if (!command.startsWith("geyser ") && extension == null) {
+            return false;
+        }
+
+        command = command.trim().replace(extension != null ? extension.description().id() + " " : "geyser ", "");
+        String label;
+        String[] args;
+
+        if (!command.contains(" ")) {
+            label = command.toLowerCase(Locale.ROOT);
+            args = new String[0];
+        } else {
+            label = command.substring(0, command.indexOf(" ")).toLowerCase(Locale.ROOT);
+            String argLine = command.substring(command.indexOf(" ") + 1);
+            args = argLine.contains(" ") ? argLine.split(" ") : new String[] { argLine };
+        }
+
+        Command cmd = (extension != null ? this.extensionCommands.getOrDefault(extension, Collections.emptyMap()) : this.commands).get(label);
+        if (cmd == null) {
+            sender.sendMessage(GeyserLocale.getLocaleStringLog("geyser.commands.invalid"));
+            return false;
+        }
+
+        if (cmd instanceof GeyserCommand) {
+            if (sender instanceof GeyserSession) {
+                ((GeyserCommand) cmd).execute((GeyserSession) sender, sender, args);
+            } else {
+                if (!cmd.isBedrockOnly()) {
+                    ((GeyserCommand) cmd).execute(null, sender, args);
+                } else {
+                    geyser.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.bootstrap.command.bedrock_only"));
+                }
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Returns the description of the given command
+     *
+     * @param command Command to get the description for
+     * @return Command description
+     */
+    public abstract String description(String command);
+
+    @RequiredArgsConstructor
+    public static class CommandBuilder<T extends CommandSource> implements Command.Builder<T> {
+        private final Extension extension;
+        private Class<? extends T> sourceType;
+        private String name;
+        private String description = "";
+        private String permission = "";
+        private List<String> aliases;
+        private boolean suggestedOpOnly = false;
+        private boolean executableOnConsole = true;
+        private List<String> subCommands;
+        private boolean bedrockOnly;
+        private CommandExecutor<T> executor;
+
+        @Override
+        public Command.Builder<T> source(@NonNull Class<? extends T> sourceType) {
+            this.sourceType = sourceType;
+            return this;
+        }
+
+        public CommandBuilder<T> name(@NonNull String name) {
+            this.name = name;
+            return this;
+        }
+
+        public CommandBuilder<T> description(@NonNull String description) {
+            this.description = description;
+            return this;
+        }
+
+        public CommandBuilder<T> permission(@NonNull String permission) {
+            this.permission = permission;
+            return this;
+        }
+
+        public CommandBuilder<T> aliases(@NonNull List<String> aliases) {
+            this.aliases = aliases;
+            return this;
+        }
+
+        @Override
+        public Command.Builder<T> suggestedOpOnly(boolean suggestedOpOnly) {
+            this.suggestedOpOnly = suggestedOpOnly;
+            return this;
+        }
+
+        public CommandBuilder<T> executableOnConsole(boolean executableOnConsole) {
+            this.executableOnConsole = executableOnConsole;
+            return this;
+        }
+
+        public CommandBuilder<T> subCommands(@NonNull List<String> subCommands) {
+            this.subCommands = subCommands;
+            return this;
+        }
+
+        public CommandBuilder<T> bedrockOnly(boolean bedrockOnly) {
+            this.bedrockOnly = bedrockOnly;
+            return this;
+        }
+
+        public CommandBuilder<T> executor(@NonNull CommandExecutor<T> executor) {
+            this.executor = executor;
+            return this;
+        }
+
+        @NonNull
+        public GeyserExtensionCommand build() {
+            if (this.name == null || this.name.isBlank()) {
+                throw new IllegalArgumentException("Command cannot be null or blank!");
+            }
+
+            if (this.sourceType == null) {
+                throw new IllegalArgumentException("Source type was not defined for command " + this.name + " in extension " + this.extension.name());
+            }
+
+            return new GeyserExtensionCommand(this.extension, this.name, this.description, this.permission) {
+
+                @SuppressWarnings("unchecked")
+                @Override
+                public void execute(@Nullable GeyserSession session, GeyserCommandSource sender, String[] args) {
+                    Class<? extends T> sourceType = CommandBuilder.this.sourceType;
+                    CommandExecutor<T> executor = CommandBuilder.this.executor;
+                    if (sourceType.isInstance(session)) {
+                        executor.execute((T) session, this, args);
+                        return;
+                    }
+
+                    if (sourceType.isInstance(sender)) {
+                        executor.execute((T) sender, this, args);
+                        return;
+                    }
+
+                    GeyserImpl.getInstance().getLogger().debug("Ignoring command " + this.name + " due to no suitable sender.");
+                }
+
+                @NonNull
+                @Override
+                public List<String> aliases() {
+                    return CommandBuilder.this.aliases == null ? Collections.emptyList() : CommandBuilder.this.aliases;
+                }
+
+                @Override
+                public boolean isSuggestedOpOnly() {
+                    return CommandBuilder.this.suggestedOpOnly;
+                }
+
+                @NonNull
+                @Override
+                public List<String> subCommands() {
+                    return CommandBuilder.this.subCommands == null ? Collections.emptyList() : CommandBuilder.this.subCommands;
+                }
+
+                @Override
+                public boolean isBedrockOnly() {
+                    return CommandBuilder.this.bedrockOnly;
+                }
+
+                @Override
+                public boolean isExecutableOnConsole() {
+                    return CommandBuilder.this.executableOnConsole;
+                }
+            };
+        }
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/command/CommandSender.java b/core/src/main/java/org/geysermc/geyser/command/GeyserCommandSource.java
similarity index 68%
rename from core/src/main/java/org/geysermc/geyser/command/CommandSender.java
rename to core/src/main/java/org/geysermc/geyser/command/GeyserCommandSource.java
index 61adad717..88d148b11 100644
--- a/core/src/main/java/org/geysermc/geyser/command/CommandSender.java
+++ b/core/src/main/java/org/geysermc/geyser/command/GeyserCommandSource.java
@@ -25,49 +25,25 @@
 
 package org.geysermc.geyser.command;
 
+import org.geysermc.geyser.api.command.CommandSource;
+import org.geysermc.geyser.text.GeyserLocale;
 import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
-import org.geysermc.geyser.text.GeyserLocale;
 
 /**
  * Implemented on top of any class that can send a command.
  * For example, it wraps around Spigot's CommandSender class.
  */
-public interface CommandSender {
+public interface GeyserCommandSource extends CommandSource {
 
-    String name();
-
-    default void sendMessage(String[] messages) {
-        for (String message : messages) {
-            sendMessage(message);
-        }
+    /**
+     * {@inheritDoc}
+     */
+    default String locale() {
+        return GeyserLocale.getDefaultLocale();
     }
 
-    void sendMessage(String message);
-
     default void sendMessage(Component message) {
         sendMessage(LegacyComponentSerializer.legacySection().serialize(message));
     }
-
-    /**
-     * @return true if the specified sender is from the console.
-     */
-    boolean isConsole();
-
-    /**
-     * Returns the locale of the command sender. Defaults to the default locale at {@link GeyserLocale#getDefaultLocale()}.
-     * 
-     * @return the locale of the command sender.
-     */
-    default String getLocale() {
-        return GeyserLocale.getDefaultLocale();
-    }
-
-    /**
-     * Checks if the CommandSender has a permission
-     *
-     * @param permission The permission node to check
-     * @return true if the CommandSender has the requested permission, false if not
-     */
-    boolean hasPermission(String permission);
 }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancedTooltipsCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancedTooltipsCommand.java
index 18546c914..466515b3f 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancedTooltipsCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancedTooltipsCommand.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser.command.defaults;
 
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.MinecraftLocale;
 
@@ -36,11 +36,11 @@ public class AdvancedTooltipsCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (session != null) {
             String onOrOff = session.isAdvancedTooltips() ? "off" : "on";
             session.setAdvancedTooltips(!session.isAdvancedTooltips());
-            session.sendMessage("§l§e" + MinecraftLocale.getLocaleString("debug.prefix", session.getLocale()) + " §r" + MinecraftLocale.getLocaleString("debug.advanced_tooltips." + onOrOff, session.getLocale()));
+            session.sendMessage("§l§e" + MinecraftLocale.getLocaleString("debug.prefix", session.locale()) + " §r" + MinecraftLocale.getLocaleString("debug.advanced_tooltips." + onOrOff, session.locale()));
             session.getInventoryTranslator().updateInventory(session, session.getPlayerInventory());
         }
     }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancementsCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancementsCommand.java
index 169158572..28253433f 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancementsCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/AdvancementsCommand.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser.command.defaults;
 
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 
 public class AdvancementsCommand extends GeyserCommand {
@@ -35,7 +35,7 @@ public class AdvancementsCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (session != null) {
             session.getAdvancementsCache().buildAndShowMenuForm();
         }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/ConnectionTestCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/ConnectionTestCommand.java
index 576d17128..95c115769 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/ConnectionTestCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/ConnectionTestCommand.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.command.defaults;
 import com.fasterxml.jackson.databind.JsonNode;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.LoopbackUtil;
@@ -47,10 +47,10 @@ public class ConnectionTestCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(@Nullable GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(@Nullable GeyserSession session, GeyserCommandSource sender, String[] args) {
         // Only allow the console to create dumps on Geyser Standalone
         if (!sender.isConsole() && geyser.getPlatformType() == PlatformType.STANDALONE) {
-            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.getLocale()));
+            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.locale()));
             return;
         }
 
@@ -69,13 +69,13 @@ public class ConnectionTestCommand extends GeyserCommand {
         }
 
         // Issue: do the ports not line up?
-        if (port != geyser.getConfig().getBedrock().getPort()) {
+        if (port != geyser.getConfig().getBedrock().port()) {
             sender.sendMessage("The port you supplied (" + port + ") does not match the port supplied in Geyser's configuration ("
-                    + geyser.getConfig().getBedrock().getPort() + "). You can change it under `bedrock` `port`.");
+                    + geyser.getConfig().getBedrock().port() + "). You can change it under `bedrock` `port`.");
         }
 
         // Issue: is the `bedrock` `address` in the config different?
-        if (!geyser.getConfig().getBedrock().getAddress().equals("0.0.0.0")) {
+        if (!geyser.getConfig().getBedrock().address().equals("0.0.0.0")) {
             sender.sendMessage("The address specified in `bedrock` `address` is not \"0.0.0.0\" - this may cause issues unless this is deliberate and intentional.");
         }
 
@@ -129,7 +129,7 @@ public class ConnectionTestCommand extends GeyserCommand {
         });
     }
 
-    private void sendLinks(CommandSender sender) {
+    private void sendLinks(GeyserCommandSource sender) {
         sender.sendMessage("If you still have issues, check to see if your hosting provider has a specific setup: " +
                 "https://wiki.geysermc.org/geyser/supported-hosting-providers/" + ", see this page: "
                 + "https://wiki.geysermc.org/geyser/fixing-unable-to-connect-to-world/" + ", or contact us on our Discord: " + "https://discord.gg/geysermc");
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/DumpCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/DumpCommand.java
index 0bac381ba..60683d34a 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/DumpCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/DumpCommand.java
@@ -29,14 +29,15 @@ import com.fasterxml.jackson.core.util.DefaultIndenter;
 import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.checkerframework.checker.nullness.qual.NonNull;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
-import org.geysermc.geyser.text.ChatColor;
-import org.geysermc.geyser.text.AsteriskSerializer;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.dump.DumpInfo;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.AsteriskSerializer;
+import org.geysermc.geyser.text.ChatColor;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.WebUtils;
 
@@ -58,10 +59,10 @@ public class DumpCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         // Only allow the console to create dumps on Geyser Standalone
         if (!sender.isConsole() && geyser.getPlatformType() == PlatformType.STANDALONE) {
-            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.getLocale()));
+            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.locale()));
             return;
         }
 
@@ -80,7 +81,7 @@ public class DumpCommand extends GeyserCommand {
 
         AsteriskSerializer.showSensitive = showSensitive;
 
-        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.collecting", sender.getLocale()));
+        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.collecting", sender.locale()));
         String dumpData;
         try {
             if (offlineDump) {
@@ -92,7 +93,7 @@ public class DumpCommand extends GeyserCommand {
                 dumpData = MAPPER.writeValueAsString(new DumpInfo(addLog));
             }
         } catch (IOException e) {
-            sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.collect_error", sender.getLocale()));
+            sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.collect_error", sender.locale()));
             geyser.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.commands.dump.collect_error_short"), e);
             return;
         }
@@ -100,21 +101,21 @@ public class DumpCommand extends GeyserCommand {
         String uploadedDumpUrl = "";
 
         if (offlineDump) {
-            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.writing", sender.getLocale()));
+            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.writing", sender.locale()));
 
             try {
                 FileOutputStream outputStream = new FileOutputStream(GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("dump.json").toFile());
                 outputStream.write(dumpData.getBytes());
                 outputStream.close();
             } catch (IOException e) {
-                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.write_error", sender.getLocale()));
+                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.write_error", sender.locale()));
                 geyser.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.commands.dump.write_error_short"), e);
                 return;
             }
 
             uploadedDumpUrl = "dump.json";
         } else {
-            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.uploading", sender.getLocale()));
+            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.uploading", sender.locale()));
 
             String response;
             JsonNode responseNode;
@@ -122,27 +123,28 @@ public class DumpCommand extends GeyserCommand {
                 response = WebUtils.post(DUMP_URL + "documents", dumpData);
                 responseNode = MAPPER.readTree(response);
             } catch (IOException e) {
-                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.upload_error", sender.getLocale()));
+                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.upload_error", sender.locale()));
                 geyser.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.commands.dump.upload_error_short"), e);
                 return;
             }
 
             if (!responseNode.has("key")) {
-                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.upload_error_short", sender.getLocale()) + ": " + (responseNode.has("message") ? responseNode.get("message").asText() : response));
+                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.dump.upload_error_short", sender.locale()) + ": " + (responseNode.has("message") ? responseNode.get("message").asText() : response));
                 return;
             }
 
             uploadedDumpUrl = DUMP_URL + responseNode.get("key").asText();
         }
 
-        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.message", sender.getLocale()) + " " + ChatColor.DARK_AQUA + uploadedDumpUrl);
+        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.dump.message", sender.locale()) + " " + ChatColor.DARK_AQUA + uploadedDumpUrl);
         if (!sender.isConsole()) {
             geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.commands.dump.created", sender.name(), uploadedDumpUrl));
         }
     }
 
+    @NonNull
     @Override
-    public List<String> getSubCommands() {
+    public List<String> subCommands() {
         return Arrays.asList("offline", "full", "logs");
     }
 
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/ExtensionsCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/ExtensionsCommand.java
new file mode 100644
index 000000000..30d422b23
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/ExtensionsCommand.java
@@ -0,0 +1,66 @@
+/*
+ * 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.command.defaults;
+
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.text.GeyserLocale;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Comparator;
+import java.util.List;
+
+public class ExtensionsCommand extends GeyserCommand {
+    private final GeyserImpl geyser;
+
+    public ExtensionsCommand(GeyserImpl geyser, String name, String description, String permission) {
+        super(name, description, permission);
+
+        this.geyser = geyser;
+    }
+
+    @Override
+    public void execute(@Nullable GeyserSession session, GeyserCommandSource sender, String[] args) {
+        // TODO: Pagination
+        int page = 1;
+        int maxPage = 1;
+        String header = GeyserLocale.getPlayerLocaleString("geyser.commands.extensions.header", sender.locale(), page, maxPage);
+        sender.sendMessage(header);
+
+        this.geyser.extensionManager().extensions().stream().sorted(Comparator.comparing(Extension::name)).forEach(extension -> {
+            String extensionName = (extension.isEnabled() ? ChatColor.GREEN : ChatColor.RED) + extension.name();
+            sender.sendMessage("- " + extensionName + ChatColor.RESET + " v" + extension.description().version() + formatAuthors(extension.description().authors()));
+        });
+    }
+
+    private String formatAuthors(List<String> authors) {
+        return authors.isEmpty() ? "" : " by: " + String.join(", ", authors);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/HelpCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/HelpCommand.java
index 85682b294..6e7ad2f04 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/HelpCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/HelpCommand.java
@@ -27,10 +27,11 @@ package org.geysermc.geyser.command.defaults;
 
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.api.command.Command;
 import org.geysermc.geyser.command.GeyserCommand;
-import org.geysermc.geyser.text.ChatColor;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.ChatColor;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.Collections;
@@ -38,10 +39,15 @@ import java.util.Map;
 
 public class HelpCommand extends GeyserCommand {
     private final GeyserImpl geyser;
+    private final String baseCommand;
+    private final Map<String, Command> commands;
 
-    public HelpCommand(GeyserImpl geyser, String name, String description, String permission) {
+    public HelpCommand(GeyserImpl geyser, String name, String description, String permission,
+                       String baseCommand, Map<String, Command> commands) {
         super(name, description, permission);
         this.geyser = geyser;
+        this.baseCommand = baseCommand;
+        this.commands = commands;
 
         this.setAliases(Collections.singletonList("?"));
     }
@@ -54,26 +60,25 @@ public class HelpCommand extends GeyserCommand {
      * @param args Not used.
      */
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         int page = 1;
         int maxPage = 1;
-        String header = GeyserLocale.getPlayerLocaleString("geyser.commands.help.header", sender.getLocale(), page, maxPage);
+        String header = GeyserLocale.getPlayerLocaleString("geyser.commands.help.header", sender.locale(), page, maxPage);
         sender.sendMessage(header);
 
-        Map<String, GeyserCommand> cmds = geyser.getCommandManager().getCommands();
-        for (Map.Entry<String, GeyserCommand> entry : cmds.entrySet()) {
-            GeyserCommand cmd = entry.getValue();
+        this.commands.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
+            Command cmd = entry.getValue();
 
             // Standalone hack-in since it doesn't have a concept of permissions
-            if (geyser.getPlatformType() == PlatformType.STANDALONE || sender.hasPermission(cmd.getPermission())) {
+            if (geyser.getPlatformType() == PlatformType.STANDALONE || sender.hasPermission(cmd.permission())) {
                 // Only list commands the player can actually run
                 if (cmd.isBedrockOnly() && session == null) {
-                    continue;
+                    return;
                 }
 
-                sender.sendMessage(ChatColor.YELLOW + "/geyser " + entry.getKey() + ChatColor.WHITE + ": " +
-                        GeyserLocale.getPlayerLocaleString(cmd.getDescription(), sender.getLocale()));
+                sender.sendMessage(ChatColor.YELLOW + "/" + baseCommand + " " + entry.getKey() + ChatColor.WHITE + ": " +
+                        GeyserLocale.getPlayerLocaleString(cmd.description(), sender.locale()));
             }
-        }
+        });
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/ListCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/ListCommand.java
index 0a4cfa023..90446fbb6 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/ListCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/ListCommand.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.command.defaults;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 
@@ -44,10 +44,10 @@ public class ListCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
-        String message = GeyserLocale.getPlayerLocaleString("geyser.commands.list.message", sender.getLocale(),
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
+        String message = GeyserLocale.getPlayerLocaleString("geyser.commands.list.message", sender.locale(),
                 geyser.getSessionManager().size(),
-                geyser.getSessionManager().getAllSessions().stream().map(GeyserSession::name).collect(Collectors.joining(" ")));
+                geyser.getSessionManager().getAllSessions().stream().map(GeyserSession::bedrockUsername).collect(Collectors.joining(" ")));
 
         sender.sendMessage(message);
     }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/OffhandCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/OffhandCommand.java
index 48afd21fe..bba2e8d21 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/OffhandCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/OffhandCommand.java
@@ -30,8 +30,8 @@ import com.github.steveice10.mc.protocol.data.game.entity.player.PlayerAction;
 import com.github.steveice10.mc.protocol.packet.ingame.serverbound.player.ServerboundPlayerActionPacket;
 import com.nukkitx.math.vector.Vector3i;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 
 public class OffhandCommand extends GeyserCommand {
@@ -41,7 +41,7 @@ public class OffhandCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (session == null) {
             return;
         }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/ReloadCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/ReloadCommand.java
index e970e5d3d..843e93de0 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/ReloadCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/ReloadCommand.java
@@ -27,8 +27,8 @@ package org.geysermc.geyser.command.defaults;
 
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 
@@ -42,12 +42,12 @@ public class ReloadCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (!sender.isConsole() && geyser.getPlatformType() == PlatformType.STANDALONE) {
             return;
         }
 
-        String message = GeyserLocale.getPlayerLocaleString("geyser.commands.reload.message", sender.getLocale());
+        String message = GeyserLocale.getPlayerLocaleString("geyser.commands.reload.message", sender.locale());
 
         sender.sendMessage(message);
 
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/SettingsCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/SettingsCommand.java
index 58d778ba9..7828cf1d2 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/SettingsCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/SettingsCommand.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.command.defaults;
 
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.SettingsUtils;
 
@@ -37,7 +37,7 @@ public class SettingsCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (session != null) {
             session.sendForm(SettingsUtils.buildForm(session));
         }
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/StatisticsCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/StatisticsCommand.java
index e54b0fb9b..ea2da51df 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/StatisticsCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/StatisticsCommand.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.command.defaults;
 import com.github.steveice10.mc.protocol.data.game.ClientCommand;
 import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundClientCommandPacket;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 
 public class StatisticsCommand extends GeyserCommand {
@@ -39,7 +39,7 @@ public class StatisticsCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (session == null) return;
 
         session.setWaitingForStatistics(true);
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/StopCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/StopCommand.java
index 9c7bd8140..151aa2d84 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/StopCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/StopCommand.java
@@ -27,8 +27,8 @@ package org.geysermc.geyser.command.defaults;
 
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 
@@ -46,9 +46,9 @@ public class StopCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         if (!sender.isConsole() && geyser.getPlatformType() == PlatformType.STANDALONE) {
-            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.getLocale()));
+            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.bootstrap.command.permission_fail", sender.locale()));
             return;
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/command/defaults/VersionCommand.java b/core/src/main/java/org/geysermc/geyser/command/defaults/VersionCommand.java
index f4f62892a..fbe4fb4f6 100644
--- a/core/src/main/java/org/geysermc/geyser/command/defaults/VersionCommand.java
+++ b/core/src/main/java/org/geysermc/geyser/command/defaults/VersionCommand.java
@@ -28,20 +28,18 @@ package org.geysermc.geyser.command.defaults;
 import com.nukkitx.protocol.bedrock.BedrockPacketCodec;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.command.GeyserCommand;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.command.GeyserCommandSource;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.ChatColor;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.WebUtils;
 
 import java.io.IOException;
-import java.io.InputStream;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
-import java.util.Properties;
 
 public class VersionCommand extends GeyserCommand {
 
@@ -54,49 +52,46 @@ public class VersionCommand extends GeyserCommand {
     }
 
     @Override
-    public void execute(GeyserSession session, CommandSender sender, String[] args) {
+    public void execute(GeyserSession session, GeyserCommandSource sender, String[] args) {
         String bedrockVersions;
-        List<BedrockPacketCodec> supportedCodecs = MinecraftProtocol.SUPPORTED_BEDROCK_CODECS;
+        List<BedrockPacketCodec> supportedCodecs = GameProtocol.SUPPORTED_BEDROCK_CODECS;
         if (supportedCodecs.size() > 1) {
             bedrockVersions = supportedCodecs.get(0).getMinecraftVersion() + " - " + supportedCodecs.get(supportedCodecs.size() - 1).getMinecraftVersion();
         } else {
-            bedrockVersions = MinecraftProtocol.SUPPORTED_BEDROCK_CODECS.get(0).getMinecraftVersion();
+            bedrockVersions = GameProtocol.SUPPORTED_BEDROCK_CODECS.get(0).getMinecraftVersion();
         }
         String javaVersions;
-        List<String> supportedJavaVersions = MinecraftProtocol.getJavaVersions();
+        List<String> supportedJavaVersions = GameProtocol.getJavaVersions();
         if (supportedJavaVersions.size() > 1) {
             javaVersions = supportedJavaVersions.get(0) + " - " + supportedJavaVersions.get(supportedJavaVersions.size() - 1);
         } else {
             javaVersions = supportedJavaVersions.get(0);
         }
 
-        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.version", sender.getLocale(),
+        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.version", sender.locale(),
                 GeyserImpl.NAME, GeyserImpl.VERSION, javaVersions, bedrockVersions));
 
         // Disable update checking in dev mode and for players in Geyser Standalone
-        if (GeyserImpl.getInstance().productionEnvironment() && !(!sender.isConsole() && geyser.getPlatformType() == PlatformType.STANDALONE)) {
-            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.checking", sender.getLocale()));
-            try (InputStream stream = GeyserImpl.getInstance().getBootstrap().getResource("git.properties")) {
-                Properties gitProp = new Properties();
-                gitProp.load(stream);
-
+        if (GeyserImpl.getInstance().isProductionEnvironment() && !(!sender.isConsole() && geyser.getPlatformType() == PlatformType.STANDALONE)) {
+            sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.checking", sender.locale()));
+            try {
                 String buildXML = WebUtils.getBody("https://ci.opencollab.dev/job/GeyserMC/job/Geyser/job/" +
-                        URLEncoder.encode(gitProp.getProperty("git.branch"), StandardCharsets.UTF_8.toString()) + "/lastSuccessfulBuild/api/xml?xpath=//buildNumber");
+                        URLEncoder.encode(GeyserImpl.BRANCH, StandardCharsets.UTF_8.toString()) + "/lastSuccessfulBuild/api/xml?xpath=//buildNumber");
                 if (buildXML.startsWith("<buildNumber>")) {
                     int latestBuildNum = Integer.parseInt(buildXML.replaceAll("<(\\\\)?(/)?buildNumber>", "").trim());
-                    int buildNum = Integer.parseInt(gitProp.getProperty("git.build.number"));
+                    int buildNum = this.geyser.buildNumber();
                     if (latestBuildNum == buildNum) {
-                        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.no_updates", sender.getLocale()));
+                        sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.no_updates", sender.locale()));
                     } else {
                         sender.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.commands.version.outdated",
-                                sender.getLocale(), (latestBuildNum - buildNum), "https://ci.geysermc.org/"));
+                                sender.locale(), (latestBuildNum - buildNum), "https://ci.geysermc.org/"));
                     }
                 } else {
                     throw new AssertionError("buildNumber missing");
                 }
-            } catch (IOException | AssertionError | NumberFormatException e) {
+            } catch (IOException e) {
                 GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.commands.version.failed"), e);
-                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.version.failed", sender.getLocale()));
+                sender.sendMessage(ChatColor.RED + GeyserLocale.getPlayerLocaleString("geyser.commands.version.failed", sender.locale()));
             }
         }
     }
diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
index 4d9b3f2a4..109ad3211 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
@@ -27,8 +27,10 @@ package org.geysermc.geyser.configuration;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.session.auth.AuthType;
+import org.geysermc.geyser.api.network.BedrockListener;
+import org.geysermc.geyser.api.network.RemoteServer;
 import org.geysermc.geyser.network.CIDRMatcher;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.nio.file.Path;
@@ -113,20 +115,10 @@ public interface GeyserConfiguration {
 
     int getPendingAuthenticationTimeout();
 
-    interface IBedrockConfiguration {
-
-        String getAddress();
-
-        int getPort();
+    interface IBedrockConfiguration extends BedrockListener {
 
         boolean isCloneRemotePort();
 
-        String getMotd1();
-
-        String getMotd2();
-
-        String getServerName();
-
         int getCompressionLevel();
 
         boolean isEnableProxyProtocol();
@@ -139,23 +131,25 @@ public interface GeyserConfiguration {
         List<CIDRMatcher> getWhitelistedIPsMatchers();
     }
 
-    interface IRemoteConfiguration {
-
-        String getAddress();
-
-        int getPort();
+    interface IRemoteConfiguration extends RemoteServer {
 
         void setAddress(String address);
 
         void setPort(int port);
 
-        AuthType getAuthType();
-
         boolean isPasswordAuthentication();
 
         boolean isUseProxyProtocol();
 
         boolean isForwardHost();
+
+        default String minecraftVersion() {
+            return GameProtocol.getJavaMinecraftVersion();
+        }
+
+        default int protocolVersion() {
+            return GameProtocol.getJavaProtocolVersion();
+        }
     }
 
     interface IUserAuthenticationInfo {
diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
index b93b9a6a0..73e208963 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
@@ -35,8 +35,8 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import lombok.Getter;
 import lombok.Setter;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.network.CIDRMatcher;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.text.AsteriskSerializer;
 import org.geysermc.geyser.text.GeyserLocale;
 
@@ -159,24 +159,54 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
     @JsonProperty("pending-authentication-timeout")
     private int pendingAuthenticationTimeout = 120;
 
-    @Getter
     @JsonIgnoreProperties(ignoreUnknown = true)
     public static class BedrockConfiguration implements IBedrockConfiguration {
         @AsteriskSerializer.Asterisk(isIp = true)
+        @JsonProperty("address")
         private String address = "0.0.0.0";
 
+        @Override
+        public String address() {
+            return address;
+        }
+
         @Setter
+        @JsonProperty("port")
         private int port = 19132;
 
+        @Override
+        public int port() {
+            return port;
+        }
+
+        @Getter
         @JsonProperty("clone-remote-port")
         private boolean cloneRemotePort = false;
 
+        @JsonProperty("motd1")
         private String motd1 = "GeyserMC";
+
+        @Override
+        public String primaryMotd() {
+            return motd1;
+        }
+
+        @JsonProperty("motd2")
         private String motd2 = "Geyser";
 
+        @Override
+        public String secondaryMotd() {
+            return motd2;
+        }
+
         @JsonProperty("server-name")
         private String serverName = GeyserImpl.NAME;
 
+        @Override
+        public String serverName() {
+            return serverName;
+        }
+
         @JsonProperty("compression-level")
         private int compressionLevel = 6;
 
@@ -184,9 +214,11 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
             return Math.max(-1, Math.min(compressionLevel, 9));
         }
 
+        @Getter
         @JsonProperty("enable-proxy-protocol")
         private boolean enableProxyProtocol = false;
 
+        @Getter
         @JsonProperty("proxy-protocol-whitelisted-ips")
         private List<String> proxyProtocolWhitelistedIPs = Collections.emptyList();
 
@@ -208,28 +240,47 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
         }
     }
 
-    @Getter
     @JsonIgnoreProperties(ignoreUnknown = true)
     public static class RemoteConfiguration implements IRemoteConfiguration {
         @Setter
         @AsteriskSerializer.Asterisk(isIp = true)
+        @JsonProperty("address")
         private String address = "auto";
 
+        @Override
+        public String address() {
+            return address;
+        }
+
         @JsonDeserialize(using = PortDeserializer.class)
         @Setter
+        @JsonProperty("port")
         private int port = 25565;
 
+        @Override
+        public int port() {
+            return port;
+        }
+
         @Setter
-        @JsonDeserialize(using = AuthType.Deserializer.class)
+        @JsonDeserialize(using = AuthTypeDeserializer.class)
         @JsonProperty("auth-type")
         private AuthType authType = AuthType.ONLINE;
 
+        @Override
+        public AuthType authType() {
+            return authType;
+        }
+
+        @Getter
         @JsonProperty("allow-password-authentication")
         private boolean passwordAuthentication = true;
 
+        @Getter
         @JsonProperty("use-proxy-protocol")
         private boolean useProxyProtocol = false;
 
+        @Getter
         @JsonProperty("forward-hostname")
         private boolean forwardHost = false;
     }
@@ -299,4 +350,11 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
             }
         }
     }
+
+    public static class AuthTypeDeserializer extends JsonDeserializer<AuthType> {
+        @Override
+        public AuthType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+            return AuthType.getByName(p.getValueAsString());
+        }
+    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/dump/DumpInfo.java b/core/src/main/java/org/geysermc/geyser/dump/DumpInfo.java
index 1c9be8c3e..5197f2107 100644
--- a/core/src/main/java/org/geysermc/geyser/dump/DumpInfo.java
+++ b/core/src/main/java/org/geysermc/geyser/dump/DumpInfo.java
@@ -26,6 +26,7 @@
 package org.geysermc.geyser.dump;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.google.common.hash.Hashing;
 import com.google.common.io.ByteSource;
@@ -35,20 +36,21 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
 import lombok.AllArgsConstructor;
 import lombok.Getter;
+import org.geysermc.floodgate.util.DeviceOs;
+import org.geysermc.floodgate.util.FloodgateInfoHolder;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.text.AsteriskSerializer;
+import org.geysermc.geyser.api.GeyserApi;
+import org.geysermc.geyser.api.extension.Extension;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.AsteriskSerializer;
 import org.geysermc.geyser.util.CpuUtils;
 import org.geysermc.geyser.util.FileUtils;
 import org.geysermc.geyser.util.WebUtils;
-import org.geysermc.floodgate.util.DeviceOs;
-import org.geysermc.floodgate.util.FloodgateInfoHolder;
 
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
 import java.lang.management.ManagementFactory;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
@@ -68,7 +70,7 @@ public class DumpInfo {
     private final String cpuName;
     private final Locale systemLocale;
     private final String systemEncoding;
-    private Properties gitInfo;
+    private final GitInfo gitInfo;
     private final GeyserConfiguration config;
     private final Floodgate floodgate;
     private final Object2IntMap<DeviceOs> userPlatforms;
@@ -77,6 +79,7 @@ public class DumpInfo {
     private LogsInfo logsInfo;
     private final BootstrapDumpInfo bootstrapInfo;
     private final FlagsInfo flagsInfo;
+    private final List<ExtensionInfo> extensionInfo;
 
     public DumpInfo(boolean addLog) {
         this.versionInfo = new VersionInfo();
@@ -86,11 +89,7 @@ public class DumpInfo {
         this.systemLocale = Locale.getDefault();
         this.systemEncoding = System.getProperty("file.encoding");
 
-        try (InputStream stream = GeyserImpl.getInstance().getBootstrap().getResource("git.properties")) {
-            this.gitInfo = new Properties();
-            this.gitInfo.load(stream);
-        } catch (IOException ignored) {
-        }
+        this.gitInfo = new GitInfo(GeyserImpl.BUILD_NUMBER, GeyserImpl.COMMIT.substring(0, 7), GeyserImpl.COMMIT, GeyserImpl.BRANCH, GeyserImpl.REPOSITORY);
 
         this.config = GeyserImpl.getInstance().getConfig();
         this.floodgate = new Floodgate();
@@ -129,6 +128,11 @@ public class DumpInfo {
         this.bootstrapInfo = GeyserImpl.getInstance().getBootstrap().getDumpInfo();
 
         this.flagsInfo = new FlagsInfo();
+
+        this.extensionInfo = new ArrayList<>();
+        for (Extension extension : GeyserApi.api().extensionManager().extensions()) {
+            this.extensionInfo.add(new ExtensionInfo(extension.isEnabled(), extension.name(), extension.description().version(), extension.description().apiVersion(), extension.description().main(), extension.description().authors()));
+        }
     }
 
     @Getter
@@ -215,11 +219,11 @@ public class DumpInfo {
         private final int javaProtocol;
 
         MCInfo() {
-            this.bedrockVersions = MinecraftProtocol.SUPPORTED_BEDROCK_CODECS.stream().map(BedrockPacketCodec::getMinecraftVersion).toList();
-            this.bedrockProtocols = MinecraftProtocol.SUPPORTED_BEDROCK_CODECS.stream().map(BedrockPacketCodec::getProtocolVersion).toList();
-            this.defaultBedrockProtocol = MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion();
-            this.javaVersions = MinecraftProtocol.getJavaVersions();
-            this.javaProtocol = MinecraftProtocol.getJavaProtocolVersion();
+            this.bedrockVersions = GameProtocol.SUPPORTED_BEDROCK_CODECS.stream().map(BedrockPacketCodec::getMinecraftVersion).toList();
+            this.bedrockProtocols = GameProtocol.SUPPORTED_BEDROCK_CODECS.stream().map(BedrockPacketCodec::getProtocolVersion).toList();
+            this.defaultBedrockProtocol = GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion();
+            this.javaVersions = GameProtocol.getJavaVersions();
+            this.javaProtocol = GameProtocol.getJavaProtocolVersion();
         }
     }
 
@@ -281,4 +285,29 @@ public class DumpInfo {
             this.flags = ManagementFactory.getRuntimeMXBean().getInputArguments();
         }
     }
+
+    @Getter
+    @AllArgsConstructor
+    public static class ExtensionInfo {
+        public boolean enabled;
+        public String name;
+        public String version;
+        public String apiVersion;
+        public String main;
+        public List<String> authors;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    public static class GitInfo {
+        private final String buildNumber;
+        @JsonProperty("git.commit.id.abbrev")
+        private final String commitHashAbbrev;
+        @JsonProperty("git.commit.id")
+        private final String commitHash;
+        @JsonProperty("git.branch")
+        private final String branchName;
+        @JsonProperty("git.remote.origin.url")
+        private final String originUrl;
+    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/AreaEffectCloudEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/AreaEffectCloudEntity.java
index 164fbf705..a38a4dd16 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/AreaEffectCloudEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/AreaEffectCloudEntity.java
@@ -32,8 +32,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.Registries;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
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 144d1cbf9..1db2e6117 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
@@ -366,7 +366,7 @@ public class Entity {
     public void setDisplayName(EntityMetadata<Optional<Component>, ?> entityMetadata) {
         Optional<Component> name = entityMetadata.getValue();
         if (name.isPresent()) {
-            nametag = MessageTranslator.convertMessage(name.get(), session.getLocale());
+            nametag = MessageTranslator.convertMessage(name.get(), session.locale());
             dirtyMetadata.put(EntityData.NAMETAG, nametag);
         } else if (!nametag.isEmpty()) {
             // Clear nametag
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/FireworkEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/FireworkEntity.java
index fa22422ba..12498f752 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/FireworkEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/FireworkEntity.java
@@ -36,12 +36,12 @@ import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.nbt.NbtType;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.packet.SetEntityMotionPacket;
+import org.geysermc.floodgate.util.DeviceOs;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.FireworkColor;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.MathUtils;
-import org.geysermc.floodgate.util.DeviceOs;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/FurnaceMinecartEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/FurnaceMinecartEntity.java
index dbd9bf91f..8074cd5ab 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/FurnaceMinecartEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/FurnaceMinecartEntity.java
@@ -30,8 +30,8 @@ import com.github.steveice10.mc.protocol.data.game.entity.player.Hand;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 
 import java.util.UUID;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/ItemEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/ItemEntity.java
index f36a7c732..89db9b0c8 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/ItemEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/ItemEntity.java
@@ -35,9 +35,9 @@ import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import com.nukkitx.protocol.bedrock.packet.AddItemEntityPacket;
 import com.nukkitx.protocol.bedrock.packet.EntityEventPacket;
 import org.geysermc.geyser.entity.EntityDefinition;
+import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
-import org.geysermc.geyser.level.block.BlockStateValues;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/ItemFrameEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/ItemFrameEntity.java
index bc7736e9b..8e4a5323a 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/ItemFrameEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/ItemFrameEntity.java
@@ -40,7 +40,6 @@ import com.nukkitx.protocol.bedrock.packet.BlockEntityDataPacket;
 import com.nukkitx.protocol.bedrock.packet.UpdateBlockPacket;
 import lombok.Getter;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
 import org.geysermc.geyser.util.InteractionResult;
@@ -114,7 +113,9 @@ public class ItemFrameEntity extends Entity {
         if (entityMetadata.getValue() != null) {
             this.heldItem = entityMetadata.getValue();
             ItemData itemData = ItemTranslator.translateToBedrock(session, heldItem);
-            ItemMapping mapping = session.getItemMappings().getMapping(entityMetadata.getValue());
+
+            String customIdentifier = session.getItemMappings().getCustomIdMappings().get(itemData.getId());
+
             NbtMapBuilder builder = NbtMap.builder();
 
             builder.putByte("Count", (byte) itemData.getCount());
@@ -122,7 +123,7 @@ public class ItemFrameEntity extends Entity {
                 builder.put("tag", itemData.getTag());
             }
             builder.putShort("Damage", (short) itemData.getDamage());
-            builder.putString("Name", mapping.getBedrockIdentifier());
+            builder.putString("Name", customIdentifier != null ? customIdentifier : session.getItemMappings().getMapping(entityMetadata.getValue()).getBedrockIdentifier());
             NbtMapBuilder tag = getDefaultTag().toBuilder();
             tag.put("Item", builder.build());
             tag.putFloat("ItemDropChance", 1.0f);
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/SpawnerMinecartEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/SpawnerMinecartEntity.java
index 5f7c906e9..cd5df1bf4 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/SpawnerMinecartEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/SpawnerMinecartEntity.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.entity.type;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/ThrowableEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/ThrowableEntity.java
index ad8b60bdb..3652860b3 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/ThrowableEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/ThrowableEntity.java
@@ -32,8 +32,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.packet.LevelEventPacket;
 import com.nukkitx.protocol.bedrock.packet.MoveEntityDeltaPacket;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/ThrownPotionEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/ThrownPotionEntity.java
index 6f6125f2d..fcfc4ff12 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/ThrownPotionEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/ThrownPotionEntity.java
@@ -34,9 +34,9 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.item.Potion;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.EnumSet;
 import java.util.UUID;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/TippedArrowEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/TippedArrowEntity.java
index d296019c1..95118f928 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/TippedArrowEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/TippedArrowEntity.java
@@ -29,8 +29,8 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.type.IntEntit
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.item.TippedArrowPotion;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/SquidEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/SquidEntity.java
index 552f6a46c..6b235a8e5 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/SquidEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/SquidEntity.java
@@ -30,8 +30,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.packet.MoveEntityDeltaPacket;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.entity.type.Tickable;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AnimalEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AnimalEntity.java
index 0da53b7c6..16a72a235 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AnimalEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AnimalEntity.java
@@ -32,8 +32,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.entity.type.living.AgeableEntity;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AxolotlEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AxolotlEntity.java
index ca7ee57a2..74652da80 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AxolotlEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/AxolotlEntity.java
@@ -33,8 +33,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.EntityUtils;
 import org.geysermc.geyser.util.InteractionResult;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/BeeEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/BeeEntity.java
index 09b1b73c5..ce02905b9 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/BeeEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/BeeEntity.java
@@ -33,8 +33,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityEventType;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.packet.EntityEventPacket;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/ChickenEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/ChickenEntity.java
index c5fad8bb8..2185d158b 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/ChickenEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/ChickenEntity.java
@@ -27,8 +27,8 @@ package org.geysermc.geyser.entity.type.living.animal;
 
 import com.nukkitx.math.vector.Vector3f;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/FoxEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/FoxEntity.java
index 5ae3bd524..8e350e685 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/FoxEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/FoxEntity.java
@@ -31,8 +31,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/OcelotEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/OcelotEntity.java
index af1fe0bad..a44a0e9f9 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/OcelotEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/OcelotEntity.java
@@ -30,8 +30,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PandaEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PandaEntity.java
index 51f595526..5e8d9c16f 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PandaEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PandaEntity.java
@@ -35,8 +35,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.packet.EntityEventPacket;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PigEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PigEntity.java
index db8a1ccd8..3b424b456 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PigEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PigEntity.java
@@ -30,8 +30,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.EntityUtils;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PolarBearEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PolarBearEntity.java
index b677c135e..1c5c47261 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PolarBearEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/PolarBearEntity.java
@@ -27,8 +27,8 @@ package org.geysermc.geyser.entity.type.living.animal;
 
 import com.nukkitx.math.vector.Vector3f;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
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 966e500b4..c49c9beb3 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
@@ -31,8 +31,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/StriderEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/StriderEntity.java
index f8d549299..fdbaad997 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/StriderEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/StriderEntity.java
@@ -29,11 +29,11 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.type.BooleanE
 import com.github.steveice10.mc.protocol.data.game.entity.player.Hand;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinition;
+import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.EntityUtils;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
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 79a7b8f50..1aa0d4fc9 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
@@ -29,8 +29,8 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.type.BooleanE
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/CatEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/CatEntity.java
index 022535592..f700f6951 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/CatEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/CatEntity.java
@@ -34,8 +34,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/ParrotEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/ParrotEntity.java
index 2b49168dd..51582e087 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/ParrotEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/ParrotEntity.java
@@ -30,8 +30,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/TameableEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/TameableEntity.java
index 33b2144e8..c95556cb4 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/TameableEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/TameableEntity.java
@@ -31,8 +31,8 @@ import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import lombok.Getter;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinition;
+import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.type.living.animal.AnimalEntity;
 import org.geysermc.geyser.session.GeyserSession;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/WolfEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/WolfEntity.java
index bc5209bcb..d6825e8a1 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/WolfEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/animal/tameable/WolfEntity.java
@@ -34,8 +34,8 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InteractionResult;
 import org.geysermc.geyser.util.InteractiveTag;
 import org.geysermc.geyser.util.ItemUtils;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/EnderDragonPartEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/EnderDragonPartEntity.java
index a169081fc..5631a68c9 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/EnderDragonPartEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/EnderDragonPartEntity.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.entity.type.living.monster;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinitions;
+import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.session.GeyserSession;
 
 public class EnderDragonPartEntity extends Entity {
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/GuardianEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/GuardianEntity.java
index fe1f3038b..e2454123f 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/GuardianEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/GuardianEntity.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.entity.type.living.monster;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.type.IntEntityMetadata;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinition;
+import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/WitherEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/WitherEntity.java
index d6926996e..81aa1ed99 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/WitherEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/WitherEntity.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.entity.type.living.monster;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.type.IntEntityMetadata;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityData;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinition;
+import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/raid/PillagerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/raid/PillagerEntity.java
index 4359c4254..716c54de1 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/raid/PillagerEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/living/monster/raid/PillagerEntity.java
@@ -28,8 +28,8 @@ package org.geysermc.geyser.entity.type.living.monster.raid;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import org.geysermc.geyser.entity.EntityDefinition;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.UUID;
 
diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java
index f16f46e2e..7a5d34973 100644
--- a/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java
@@ -73,7 +73,7 @@ public class SessionPlayerEntity extends PlayerEntity {
     private int fakeTradeXp;
 
     public SessionPlayerEntity(GeyserSession session) {
-        super(session, -1, 1, UUID.randomUUID(), Vector3f.ZERO, Vector3f.ZERO, 0, 0, 0, "unknown", null);
+        super(session, -1, 1, null, Vector3f.ZERO, Vector3f.ZERO, 0, 0, 0, null, null);
 
         valid = true;
     }
diff --git a/core/src/main/java/org/geysermc/geyser/event/GeyserEventBus.java b/core/src/main/java/org/geysermc/geyser/event/GeyserEventBus.java
new file mode 100644
index 000000000..9593e327e
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/event/GeyserEventBus.java
@@ -0,0 +1,72 @@
+/*
+ * 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.event;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.event.PostOrder;
+import org.geysermc.event.bus.impl.OwnedEventBusImpl;
+import org.geysermc.event.subscribe.OwnedSubscriber;
+import org.geysermc.event.subscribe.Subscribe;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.event.EventSubscriber;
+import org.geysermc.geyser.api.extension.Extension;
+
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+@SuppressWarnings("unchecked")
+public final class GeyserEventBus extends OwnedEventBusImpl<EventRegistrar, Event, EventSubscriber<EventRegistrar, ? extends Event>>
+        implements EventBus<EventRegistrar> {
+    @Override
+    protected <L, T extends Event, B extends OwnedSubscriber<EventRegistrar, T>> B makeSubscription(
+            @NonNull EventRegistrar owner,
+            @NonNull Class<T> eventClass,
+            @NonNull Subscribe subscribe,
+            @NonNull L listener,
+            @NonNull BiConsumer<L, T> handler) {
+        return (B) new GeyserEventSubscriber<>(
+                owner, eventClass, subscribe.postOrder(), subscribe.ignoreCancelled(), listener, handler
+        );
+    }
+
+    @Override
+    protected <T extends Event, B extends OwnedSubscriber<EventRegistrar, T>> B makeSubscription(
+            @NonNull EventRegistrar owner,
+            @NonNull Class<T> eventClass,
+            @NonNull Consumer<T> handler,
+            @NonNull PostOrder postOrder) {
+        return (B) new GeyserEventSubscriber<>(owner, eventClass, handler, postOrder);
+    }
+
+    @Override
+    @NonNull
+    public <T extends Event> Set<? extends EventSubscriber<EventRegistrar, T>> subscribers(@NonNull Class<T> eventClass) {
+        return castGenericSet(super.subscribers(eventClass));
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/event/GeyserEventRegistrar.java b/core/src/main/java/org/geysermc/geyser/event/GeyserEventRegistrar.java
new file mode 100644
index 000000000..85c36a132
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/event/GeyserEventRegistrar.java
@@ -0,0 +1,38 @@
+/*
+ * 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.event;
+
+import org.geysermc.geyser.api.event.EventRegistrar;
+
+public record GeyserEventRegistrar(Object owner) implements EventRegistrar {
+
+    @Override
+    public String toString() {
+        return "GeyserEventRegistrar{" +
+                "owner=" + this.owner +
+                '}';
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/event/GeyserEventSubscriber.java b/core/src/main/java/org/geysermc/geyser/event/GeyserEventSubscriber.java
new file mode 100644
index 000000000..d33de8cdd
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/event/GeyserEventSubscriber.java
@@ -0,0 +1,58 @@
+/*
+ * 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.event;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.event.PostOrder;
+import org.geysermc.event.subscribe.impl.OwnedSubscriberImpl;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.event.ExtensionEventSubscriber;
+import org.geysermc.geyser.api.extension.Extension;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+public final class GeyserEventSubscriber<R extends EventRegistrar, E extends Event> extends OwnedSubscriberImpl<R, E>
+        implements ExtensionEventSubscriber<E> {
+    GeyserEventSubscriber(
+            @NonNull R owner,
+            @NonNull Class<E> eventClass,
+            @NonNull Consumer<E> handler,
+            @NonNull PostOrder postOrder) {
+        super(owner, eventClass, handler, postOrder);
+    }
+
+    <H> GeyserEventSubscriber(
+            @NonNull R owner,
+            @NonNull Class<E> eventClass,
+            @NonNull PostOrder postOrder,
+            boolean ignoreCancelled,
+            @NonNull H handlerInstance,
+            @NonNull BiConsumer<H, E> handler) {
+        super(owner, eventClass, postOrder, ignoreCancelled, handlerInstance, handler);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCommandsEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCommandsEventImpl.java
new file mode 100644
index 000000000..e07a62d8a
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCommandsEventImpl.java
@@ -0,0 +1,46 @@
+/*
+ * 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.event.type;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCommandsEvent;
+
+import java.util.Collections;
+import java.util.Map;
+
+public abstract class GeyserDefineCommandsEventImpl implements GeyserDefineCommandsEvent {
+    private final Map<String, Command> commands;
+
+    public GeyserDefineCommandsEventImpl(Map<String, Command> commands) {
+        this.commands = commands;
+    }
+
+    @Override
+    public @NonNull Map<String, Command> commands() {
+        return Collections.unmodifiableMap(this.commands);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java
new file mode 100644
index 000000000..65fd7ea0d
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java
@@ -0,0 +1,85 @@
+/*
+ * 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.event.type;
+
+import com.google.common.collect.Multimap;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomItemsEvent;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public abstract class GeyserDefineCustomItemsEventImpl implements GeyserDefineCustomItemsEvent {
+    private final Multimap<String, CustomItemData> customItems;
+    private final List<NonVanillaCustomItemData> nonVanillaCustomItems;
+
+    public GeyserDefineCustomItemsEventImpl(Multimap<String, CustomItemData> customItems, List<NonVanillaCustomItemData> nonVanillaCustomItems) {
+        this.customItems = customItems;
+        this.nonVanillaCustomItems = nonVanillaCustomItems;
+    }
+
+    /**
+     * Gets a multimap of all the already registered custom items indexed by the item's extended java item's identifier.
+     *
+     * @return a multimap of all the already registered custom items
+     */
+    @Override
+    public Map<String, Collection<CustomItemData>> getExistingCustomItems() {
+        return Collections.unmodifiableMap(this.customItems.asMap());
+    }
+
+    /**
+     * Gets the list of the already registered non-vanilla custom items.
+     *
+     * @return the list of the already registered non-vanilla custom items
+     */
+    @Override
+    public List<NonVanillaCustomItemData> getExistingNonVanillaCustomItems() {
+        return Collections.unmodifiableList(this.nonVanillaCustomItems);
+    }
+
+    /**
+     * Registers a custom item with a base Java item. This is used to register items with custom textures and properties
+     * based on NBT data.
+     *
+     * @param identifier the base (java) item
+     * @param customItemData the custom item data to register
+     * @return if the item was registered
+     */
+    public abstract boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData);
+
+    /**
+     * Registers a custom item with no base item. This is used for mods.
+     *
+     * @param customItemData the custom item data to register
+     * @return if the item was registered
+     */
+    public abstract boolean register(@NonNull NonVanillaCustomItemData customItemData);
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java
new file mode 100644
index 000000000..b94e70ed0
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionClassLoader.java
@@ -0,0 +1,100 @@
+/*
+ * 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.extension;
+
+import it.unimi.dsi.fastutil.objects.Object2ObjectMap;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.extension.ExtensionDescription;
+import org.geysermc.geyser.api.extension.exception.InvalidExtensionException;
+
+import java.lang.reflect.InvocationTargetException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+
+public class GeyserExtensionClassLoader extends URLClassLoader {
+    private final GeyserExtensionLoader loader;
+    private final Object2ObjectMap<String, Class<?>> classes = new Object2ObjectOpenHashMap<>();
+
+    public GeyserExtensionClassLoader(GeyserExtensionLoader loader, ClassLoader parent, Path path) throws MalformedURLException {
+        super(new URL[] { path.toUri().toURL() }, parent);
+        this.loader = loader;
+    }
+
+    public Extension load(ExtensionDescription description) throws InvalidExtensionException {
+        try {
+            Class<?> jarClass;
+            try {
+                jarClass = Class.forName(description.main(), true, this);
+            } catch (ClassNotFoundException ex) {
+                throw new InvalidExtensionException("Class " + description.main() + " not found, extension cannot be loaded", ex);
+            }
+
+            Class<? extends Extension> extensionClass;
+            try {
+                extensionClass = jarClass.asSubclass(Extension.class);
+            } catch (ClassCastException ex) {
+                throw new InvalidExtensionException("Main class " + description.main() + " should implement Extension, but extends " + jarClass.getSuperclass().getSimpleName(), ex);
+            }
+
+            return extensionClass.getConstructor().newInstance();
+        } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException ex) {
+            throw new InvalidExtensionException("No public constructor", ex);
+        } catch (InstantiationException ex) {
+            throw new InvalidExtensionException("Abnormal extension type", ex);
+        }
+    }
+
+    @Override
+    protected Class<?> findClass(String name) throws ClassNotFoundException {
+        return this.findClass(name, true);
+    }
+
+    protected Class<?> findClass(String name, boolean checkGlobal) throws ClassNotFoundException {
+        if (name.startsWith("org.geysermc.geyser.") || name.startsWith("net.minecraft.")) {
+            throw new ClassNotFoundException(name);
+        }
+
+        Class<?> result = this.classes.get(name);
+        if (result == null) {
+            if (checkGlobal) {
+                result = this.loader.classByName(name);
+            }
+
+            if (result == null) {
+                result = super.findClass(name);
+                if (result != null) {
+                    this.loader.setClass(name, result);
+                }
+            }
+
+            this.classes.put(name, result);
+        }
+        return result;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionContainer.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionContainer.java
new file mode 100644
index 000000000..a26415a13
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionContainer.java
@@ -0,0 +1,52 @@
+/*
+ * 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.extension;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.experimental.Accessors;
+import org.geysermc.geyser.api.event.ExtensionEventBus;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.extension.ExtensionDescription;
+import org.geysermc.geyser.api.extension.ExtensionLoader;
+import org.geysermc.geyser.api.extension.ExtensionLogger;
+
+import java.nio.file.Path;
+
+@Accessors(fluent = true)
+@Getter
+@RequiredArgsConstructor
+public class GeyserExtensionContainer {
+    private final Extension extension;
+    private final Path dataFolder;
+    private final ExtensionDescription description;
+    private final ExtensionLoader loader;
+    private final ExtensionLogger logger;
+    private final ExtensionEventBus eventBus;
+
+    @Getter(AccessLevel.NONE) protected boolean enabled;
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java
new file mode 100644
index 000000000..716b763f5
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionDescription.java
@@ -0,0 +1,119 @@
+/*
+ * 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.extension;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.extension.ExtensionDescription;
+import org.geysermc.geyser.api.extension.exception.InvalidDescriptionException;
+import org.geysermc.geyser.text.GeyserLocale;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.CustomClassLoaderConstructor;
+
+import java.io.Reader;
+import java.util.*;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+public record GeyserExtensionDescription(@NonNull String id,
+                                         @NonNull String name,
+                                         @NonNull String main,
+                                         int majorApiVersion,
+                                         int minorApiVersion,
+                                         int patchApiVersion,
+                                         @NonNull String version,
+                                         @NonNull List<String> authors) implements ExtensionDescription {
+
+    private static final Yaml YAML = new Yaml(new CustomClassLoaderConstructor(Source.class.getClassLoader()));
+
+    public static final Pattern ID_PATTERN = Pattern.compile("[a-z][a-z0-9-_]{0,63}");
+    public static final Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z_.-]+$");
+    public static final Pattern API_VERSION_PATTERN = Pattern.compile("^\\d+\\.\\d+\\.\\d+$");
+
+    @NonNull
+    public static GeyserExtensionDescription fromYaml(Reader reader) throws InvalidDescriptionException {
+        Source source;
+        try {
+            source = YAML.loadAs(reader, Source.class);
+        } catch (Exception e) {
+            throw new InvalidDescriptionException(e);
+        }
+
+        String id = require(source::getId, "id");
+        if (!ID_PATTERN.matcher(id).matches()) {
+            throw new InvalidDescriptionException("Invalid extension id, must match: " + ID_PATTERN.pattern());
+        }
+
+        String name = require(source::getName, "name");
+        if (!NAME_PATTERN.matcher(name).matches()) {
+            throw new InvalidDescriptionException("Invalid extension name, must match: " + NAME_PATTERN.pattern());
+        }
+
+        String version = String.valueOf(source.version);
+        String main = require(source::getMain, "main");
+
+        String apiVersion = require(source::getApi, "api");
+        if (!API_VERSION_PATTERN.matcher(apiVersion).matches()) {
+            throw new InvalidDescriptionException(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_api_format", name, apiVersion));
+        }
+        String[] api = apiVersion.split("\\.");
+        int majorApi = Integer.parseUnsignedInt(api[0]);
+        int minorApi = Integer.parseUnsignedInt(api[1]);
+        int patchApi = Integer.parseUnsignedInt(api[2]);
+
+        List<String> authors = new ArrayList<>();
+        if (source.author != null) {
+            authors.add(source.author);
+        }
+        if (source.authors != null) {
+            authors.addAll(source.authors);
+        }
+
+        return new GeyserExtensionDescription(id, name, main, majorApi, minorApi, patchApi, version, authors);
+    }
+
+    @NonNull
+    private static String require(Supplier<String> supplier, String name) throws InvalidDescriptionException {
+        String value = supplier.get();
+        if (value == null) {
+            throw new InvalidDescriptionException("Extension description is missing string property '" + name + "'");
+        }
+        return value;
+    }
+
+    @Getter
+    @Setter
+    public static class Source {
+        String id;
+        String name;
+        String main;
+        String api;
+        String version;
+        String author;
+        List<String> authors;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java
new file mode 100644
index 000000000..7e998e413
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java
@@ -0,0 +1,222 @@
+/*
+ * 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.extension;
+
+import it.unimi.dsi.fastutil.objects.Object2ObjectMap;
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
+import lombok.RequiredArgsConstructor;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.api.Geyser;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.event.ExtensionEventBus;
+import org.geysermc.geyser.api.extension.*;
+import org.geysermc.geyser.api.extension.exception.InvalidDescriptionException;
+import org.geysermc.geyser.api.extension.exception.InvalidExtensionException;
+import org.geysermc.geyser.extension.event.GeyserExtensionEventBus;
+import org.geysermc.geyser.text.GeyserLocale;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.file.*;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+@RequiredArgsConstructor
+public class GeyserExtensionLoader extends ExtensionLoader {
+    private static final Pattern[] EXTENSION_FILTERS = new Pattern[] { Pattern.compile("^.+\\.jar$") };
+
+    private final Object2ObjectMap<String, Class<?>> classes = new Object2ObjectOpenHashMap<>();
+    private final Map<String, GeyserExtensionClassLoader> classLoaders = new HashMap<>();
+    private final Map<Extension, GeyserExtensionContainer> extensionContainers = new HashMap<>();
+    private final Path extensionsDirectory = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("extensions");
+
+    public GeyserExtensionContainer loadExtension(Path path, GeyserExtensionDescription description) throws InvalidExtensionException {
+        if (path == null) {
+            throw new InvalidExtensionException("Path is null");
+        }
+
+        if (Files.notExists(path)) {
+            throw new InvalidExtensionException(new NoSuchFileException(path.toString()) + " does not exist");
+        }
+
+        Path parentFile = path.getParent();
+        Path dataFolder = parentFile.resolve(description.name());
+        if (Files.exists(dataFolder) && !Files.isDirectory(dataFolder)) {
+            throw new InvalidExtensionException("The folder " + dataFolder + " is not a directory and is the data folder for the extension " + description.name() + "!");
+        }
+
+        final GeyserExtensionClassLoader loader;
+        try {
+            loader = new GeyserExtensionClassLoader(this, getClass().getClassLoader(), path);
+        } catch (Throwable e) {
+            throw new InvalidExtensionException(e);
+        }
+
+        this.classLoaders.put(description.name(), loader);
+
+        final Extension extension = loader.load(description);
+        return this.setup(extension, description, dataFolder, new GeyserExtensionEventBus(GeyserImpl.getInstance().eventBus(), extension));
+    }
+
+    private GeyserExtensionContainer setup(Extension extension, GeyserExtensionDescription description, Path dataFolder, ExtensionEventBus eventBus) {
+        GeyserExtensionLogger logger = new GeyserExtensionLogger(GeyserImpl.getInstance().getLogger(), description.name());
+        return new GeyserExtensionContainer(extension, dataFolder, description, this, logger, eventBus);
+    }
+
+    public GeyserExtensionDescription extensionDescription(Path path) throws InvalidDescriptionException {
+        Map<String, String> environment = new HashMap<>();
+        try (FileSystem fileSystem = FileSystems.newFileSystem(path, environment, null)) {
+            Path extensionYml = fileSystem.getPath("extension.yml");
+            try (Reader reader = Files.newBufferedReader(extensionYml)) {
+                return GeyserExtensionDescription.fromYaml(reader);
+            }
+        } catch (IOException ex) {
+            throw new InvalidDescriptionException("Failed to load extension description for " + path, ex);
+        }
+    }
+
+    public Pattern[] extensionFilters() {
+        return EXTENSION_FILTERS;
+    }
+
+    public Class<?> classByName(final String name) throws ClassNotFoundException{
+        Class<?> clazz = this.classes.get(name);
+        if (clazz != null) {
+            return clazz;
+        }
+
+        for (GeyserExtensionClassLoader loader : this.classLoaders.values()) {
+            clazz = loader.findClass(name, false);
+            if (clazz != null) {
+                break;
+            }
+        }
+
+        return clazz;
+    }
+
+    void setClass(String name, final Class<?> clazz) {
+        this.classes.putIfAbsent(name, clazz);
+    }
+
+    @Override
+    protected void loadAllExtensions(@NonNull ExtensionManager extensionManager) {
+        try {
+            if (Files.notExists(extensionsDirectory)) {
+                Files.createDirectory(extensionsDirectory);
+            }
+
+            Map<String, Path> extensions = new LinkedHashMap<>();
+            Map<String, GeyserExtensionContainer> loadedExtensions = new LinkedHashMap<>();
+
+            Pattern[] extensionFilters = this.extensionFilters();
+            try (Stream<Path> entries = Files.walk(extensionsDirectory)) {
+                entries.forEach(path -> {
+                    if (Files.isDirectory(path)) {
+                        return;
+                    }
+
+                    for (Pattern filter : extensionFilters) {
+                        if (!filter.matcher(path.getFileName().toString()).matches()) {
+                            return;
+                        }
+                    }
+
+                    try {
+                        GeyserExtensionDescription description = this.extensionDescription(path);
+
+                        String name = description.name();
+                        if (extensions.containsKey(name) || extensionManager.extension(name) != null) {
+                            GeyserImpl.getInstance().getLogger().warning(GeyserLocale.getLocaleStringLog("geyser.extensions.load.duplicate", name, path.toString()));
+                            return;
+                        }
+
+                        // Completely different API version
+                        if (description.majorApiVersion() != Geyser.api().majorApiVersion()) {
+                            GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_api_version", name, description.apiVersion()));
+                            return;
+                        }
+
+                        // If the extension requires new API features, being backwards compatible
+                        if (description.minorApiVersion() > Geyser.api().minorApiVersion()) {
+                            GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_api_version", name, description.apiVersion()));
+                            return;
+                        }
+
+                        extensions.put(name, path);
+                        loadedExtensions.put(name, this.loadExtension(path, description));
+                    } catch (Exception e) {
+                        GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.extensions.load.failed_with_name", path.getFileName(), path.toAbsolutePath()), e);
+                    }
+                });
+            }
+
+            for (GeyserExtensionContainer container : loadedExtensions.values()) {
+                this.extensionContainers.put(container.extension(), container);
+                this.register(container.extension(), extensionManager);
+            }
+        } catch (IOException ex) {
+            ex.printStackTrace();
+        }
+    }
+
+    @Override
+    protected boolean isEnabled(@NonNull Extension extension) {
+        return this.extensionContainers.get(extension).enabled;
+    }
+
+    @Override
+    protected void setEnabled(@NonNull Extension extension, boolean enabled) {
+        this.extensionContainers.get(extension).enabled = enabled;
+    }
+
+    @NonNull
+    @Override
+    protected Path dataFolder(@NonNull Extension extension) {
+        return this.extensionContainers.get(extension).dataFolder();
+    }
+
+    @NonNull
+    @Override
+    protected ExtensionDescription description(@NonNull Extension extension) {
+        return this.extensionContainers.get(extension).description();
+    }
+
+    @NonNull
+    @Override
+    protected ExtensionEventBus eventBus(@NonNull Extension extension) {
+        return this.extensionContainers.get(extension).eventBus();
+    }
+
+    @NonNull
+    @Override
+    protected ExtensionLogger logger(@NonNull Extension extension) {
+        return this.extensionContainers.get(extension).logger();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLogger.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLogger.java
new file mode 100644
index 000000000..fe23417f8
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLogger.java
@@ -0,0 +1,88 @@
+/*
+ * 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.extension;
+
+import org.geysermc.geyser.GeyserLogger;
+import org.geysermc.geyser.api.extension.ExtensionLogger;
+
+public class GeyserExtensionLogger implements ExtensionLogger {
+    private final GeyserLogger logger;
+    private final String loggerPrefix;
+
+    public GeyserExtensionLogger(GeyserLogger logger, String prefix) {
+        this.logger = logger;
+        this.loggerPrefix = prefix;
+    }
+
+    @Override
+    public String prefix() {
+        return this.loggerPrefix;
+    }
+
+    private String addPrefix(String message) {
+        return "[" + this.loggerPrefix + "] " + message;
+    }
+
+    @Override
+    public void severe(String message) {
+        this.logger.severe(this.addPrefix(message));
+    }
+
+    @Override
+    public void severe(String message, Throwable error) {
+        this.logger.severe(this.addPrefix(message), error);
+    }
+
+    @Override
+    public void error(String message) {
+        this.logger.error(this.addPrefix(message));
+    }
+
+    @Override
+    public void error(String message, Throwable error) {
+        this.logger.error(this.addPrefix(message), error);
+    }
+
+    @Override
+    public void warning(String message) {
+        this.logger.warning(this.addPrefix(message));
+    }
+
+    @Override
+    public void info(String message) {
+        this.logger.info(this.addPrefix(message));
+    }
+
+    @Override
+    public void debug(String message) {
+        this.logger.debug(this.addPrefix(message));
+    }
+
+    @Override
+    public boolean isDebug() {
+        return this.logger.isDebug();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionManager.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionManager.java
new file mode 100644
index 000000000..5dd924301
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionManager.java
@@ -0,0 +1,126 @@
+/*
+ * 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.extension;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.extension.ExtensionLoader;
+import org.geysermc.geyser.api.extension.ExtensionManager;
+import org.geysermc.geyser.text.GeyserLocale;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public class GeyserExtensionManager extends ExtensionManager {
+    private final GeyserExtensionLoader extensionLoader = new GeyserExtensionLoader();
+    private final Map<String, Extension> extensions = new LinkedHashMap<>();
+
+    public void init() {
+        GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.extensions.load.loading"));
+
+        loadAllExtensions(this.extensionLoader);
+        enableExtensions();
+
+        GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.extensions.load.done", this.extensions.size()));
+    }
+
+    @Override
+    public Extension extension(@NonNull String name) {
+        return this.extensions.get(name);
+    }
+
+    @Override
+    public void enable(@NonNull Extension extension) {
+        if (!extension.isEnabled()) {
+            try {
+                this.enableExtension(extension);
+            } catch (Exception e) {
+                GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.extensions.enable.failed", extension.name()), e);
+                this.disable(extension);
+            }
+        }
+    }
+
+    @Override
+    public void disable(@NonNull Extension extension) {
+        if (extension.isEnabled()) {
+            try {
+                this.disableExtension(extension);
+            } catch (Exception e) {
+                GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.extensions.disable.failed", extension.name()), e);
+            }
+        }
+    }
+
+    public void enableExtension(Extension extension) {
+        if (!extension.isEnabled()) {
+            extension.setEnabled(true);
+            GeyserImpl.getInstance().eventBus().register(extension, extension);
+            GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.extensions.enable.success", extension.description().name()));
+        }
+    }
+
+    public void enableExtensions() {
+        for (Extension extension : this.extensions()) {
+            this.enable(extension);
+        }
+    }
+
+    private void disableExtension(@NonNull Extension extension) {
+        if (extension.isEnabled()) {
+            GeyserImpl.getInstance().eventBus().unregisterAll(extension);
+
+            extension.setEnabled(false);
+            GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.extensions.disable.success", extension.description().name()));
+        }
+    }
+
+    public void disableExtensions() {
+        for (Extension extension : this.extensions()) {
+            this.disable(extension);
+        }
+    }
+
+    @NonNull
+    @Override
+    public Collection<Extension> extensions() {
+        return Collections.unmodifiableCollection(this.extensions.values());
+    }
+
+    @Override
+    public @Nullable ExtensionLoader extensionLoader() {
+        return this.extensionLoader;
+    }
+
+    @Override
+    public void register(@NonNull Extension extension) {
+        this.extensions.put(extension.name(), extension);
+    }
+}
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
new file mode 100644
index 000000000..4a7830c90
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/command/GeyserExtensionCommand.java
@@ -0,0 +1,43 @@
+/*
+ * 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.extension.command;
+
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.command.GeyserCommand;
+
+public abstract class GeyserExtensionCommand extends GeyserCommand {
+    private final Extension extension;
+
+    public GeyserExtensionCommand(Extension extension, String name, String description, String permission) {
+        super(name, description, permission);
+
+        this.extension = extension;
+    }
+
+    public Extension extension() {
+        return this.extension;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/extension/event/GeyserExtensionEventBus.java b/core/src/main/java/org/geysermc/geyser/extension/event/GeyserExtensionEventBus.java
new file mode 100644
index 000000000..f56b254a6
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/extension/event/GeyserExtensionEventBus.java
@@ -0,0 +1,85 @@
+/*
+ * 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.extension.event;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.event.Event;
+import org.geysermc.event.PostOrder;
+import org.geysermc.event.subscribe.Subscriber;
+import org.geysermc.geyser.api.event.EventBus;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.event.EventSubscriber;
+import org.geysermc.geyser.api.event.ExtensionEventBus;
+import org.geysermc.geyser.api.extension.Extension;
+
+import java.util.Set;
+import java.util.function.Consumer;
+
+public record GeyserExtensionEventBus(EventBus<EventRegistrar> eventBus, Extension extension) implements ExtensionEventBus {
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    @Override
+    public void unsubscribe(@NonNull EventSubscriber<Extension, ? extends Event> subscription) {
+        eventBus.unsubscribe((EventSubscriber) subscription);
+    }
+
+    @Override
+    public boolean fire(@NonNull Event event) {
+        return eventBus.fire(event);
+    }
+
+    @Override
+    public @NonNull <T extends Event> Set<? extends EventSubscriber<EventRegistrar, T>> subscribers(@NonNull Class<T> eventClass) {
+        return eventBus.subscribers(eventClass);
+    }
+
+    @Override
+    public void register(@NonNull Object listener) {
+        eventBus.register(extension, listener);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <T extends Event, U extends Subscriber<T>> @NonNull U subscribe(
+            @NonNull Class<T> eventClass, @NonNull Consumer<T> consumer) {
+        return eventBus.subscribe(extension, eventClass, consumer);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <T extends Event, U extends Subscriber<T>> @NonNull U subscribe(
+            @NonNull Class<T> eventClass,
+            @NonNull Consumer<T> consumer,
+            @NonNull PostOrder postOrder
+    ) {
+        return eventBus.subscribe(extension, eventClass, consumer, postOrder);
+    }
+
+    @Override
+    public void unregisterAll() {
+        eventBus.unregisterAll(extension);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/AnvilContainer.java b/core/src/main/java/org/geysermc/geyser/inventory/AnvilContainer.java
index 688151a9e..141f2b6f2 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/AnvilContainer.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/AnvilContainer.java
@@ -75,8 +75,8 @@ public class AnvilContainer extends Container {
 
         String originalName = ItemUtils.getCustomName(getInput().getNbt());
 
-        String plainOriginalName = MessageTranslator.convertToPlainText(originalName, session.getLocale());
-        String plainNewName = MessageTranslator.convertToPlainText(rename, session.getLocale());
+        String plainOriginalName = MessageTranslator.convertToPlainText(originalName, session.locale());
+        String plainNewName = MessageTranslator.convertToPlainText(rename, session.locale());
         if (!plainOriginalName.equals(plainNewName)) {
             // Strip out formatting since Java Edition does not allow it
             correctRename = plainNewName;
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java
index e249f0167..0fb70a1a2 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java
@@ -29,9 +29,9 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import lombok.Data;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 import javax.annotation.Nonnull;
 
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java b/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java
index 29233a2e7..f01d242ad 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java
@@ -34,7 +34,6 @@ import lombok.Getter;
 import lombok.Setter;
 import lombok.ToString;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
 import org.jetbrains.annotations.Range;
@@ -144,9 +143,9 @@ public abstract class Inventory {
 
     protected void updateItemNetId(GeyserItemStack oldItem, GeyserItemStack newItem, GeyserSession session) {
         if (!newItem.isEmpty()) {
-            ItemMapping oldMapping = ItemTranslator.getBedrockItemMapping(session, oldItem);
-            ItemMapping newMapping = ItemTranslator.getBedrockItemMapping(session, newItem);
-            if (oldMapping.getBedrockId() == newMapping.getBedrockId()) {
+            int oldMapping = ItemTranslator.getBedrockItemId(session, oldItem);
+            int newMapping = ItemTranslator.getBedrockItemId(session, newItem);
+            if (oldMapping == newMapping) {
                 newItem.setNetId(oldItem.getNetId());
             } else {
                 newItem.setNetId(session.getNextItemNetId());
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/MerchantContainer.java b/core/src/main/java/org/geysermc/geyser/inventory/MerchantContainer.java
index 315e6cb18..93c1917d2 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/MerchantContainer.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/MerchantContainer.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser.inventory;
 
-import com.github.steveice10.mc.protocol.data.game.inventory.VillagerTrade;
 import com.github.steveice10.mc.protocol.data.game.inventory.ContainerType;
+import com.github.steveice10.mc.protocol.data.game.inventory.VillagerTrade;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.inventory.ClientboundMerchantOffersPacket;
 import lombok.Getter;
 import lombok.Setter;
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java b/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java
index 027c7a7ce..d7068920e 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java
@@ -25,8 +25,6 @@
 
 package org.geysermc.geyser.inventory.click;
 
-import com.github.steveice10.mc.protocol.data.game.inventory.ClickItemAction;
-import com.github.steveice10.mc.protocol.data.game.inventory.ContainerActionType;
 import com.github.steveice10.mc.protocol.data.game.inventory.*;
 import lombok.AllArgsConstructor;
 
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserShapedRecipe.java b/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserShapedRecipe.java
index a011fef6d..d61945ad8 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserShapedRecipe.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserShapedRecipe.java
@@ -27,7 +27,6 @@ package org.geysermc.geyser.inventory.recipe;
 
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.mc.protocol.data.game.recipe.Ingredient;
-import com.github.steveice10.mc.protocol.data.game.recipe.RecipeType;
 import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapedRecipeData;
 
 public record GeyserShapedRecipe(int width, int height, Ingredient[] ingredients, ItemStack result) implements GeyserRecipe {
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/updater/AnvilInventoryUpdater.java b/core/src/main/java/org/geysermc/geyser/inventory/updater/AnvilInventoryUpdater.java
index 655d0f215..e83971443 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/updater/AnvilInventoryUpdater.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/updater/AnvilInventoryUpdater.java
@@ -27,7 +27,10 @@ package org.geysermc.geyser.inventory.updater;
 
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.packet.ingame.serverbound.inventory.ServerboundRenameItemPacket;
-import com.github.steveice10.opennbt.tag.builtin.*;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.github.steveice10.opennbt.tag.builtin.ListTag;
+import com.github.steveice10.opennbt.tag.builtin.StringTag;
+import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
@@ -40,12 +43,12 @@ import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.inventory.AnvilContainer;
 import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.text.MessageTranslator;
-import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.inventory.item.Enchantment.JavaEnchantment;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.EnchantmentData;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.InventoryTranslator;
+import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.util.ItemUtils;
 
 import java.util.Objects;
@@ -115,7 +118,7 @@ public class AnvilInventoryUpdater extends InventoryUpdater {
 
             // Changing the item in the input slot resets the name field on Bedrock, but
             // does not result in a FilterTextPacket
-            String originalName = MessageTranslator.convertToPlainText(ItemUtils.getCustomName(input.getNbt()), session.getLocale());
+            String originalName = MessageTranslator.convertToPlainText(ItemUtils.getCustomName(input.getNbt()), session.locale());
             ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(originalName);
             session.sendDownstreamPacket(renameItemPacket);
 
@@ -427,7 +430,7 @@ public class AnvilInventoryUpdater extends InventoryUpdater {
         String originalName = ItemUtils.getCustomName(anvilContainer.getInput().getNbt());
         if (bedrock && originalName != null && anvilContainer.getNewName() != null) {
             // Check text and formatting
-            String legacyOriginalName = MessageTranslator.convertMessageLenient(originalName, session.getLocale());
+            String legacyOriginalName = MessageTranslator.convertMessageLenient(originalName, session.locale());
             return !legacyOriginalName.equals(anvilContainer.getNewName());
         }
         return !Objects.equals(originalName, ItemUtils.getCustomName(anvilContainer.getResult().getNbt()));
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/updater/ChestInventoryUpdater.java b/core/src/main/java/org/geysermc/geyser/inventory/updater/ChestInventoryUpdater.java
index a468e53bc..8a174c2c5 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/updater/ChestInventoryUpdater.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/updater/ChestInventoryUpdater.java
@@ -31,9 +31,9 @@ import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
 import lombok.AllArgsConstructor;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.util.InventoryUtils;
-import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/core/src/main/java/org/geysermc/geyser/item/GeyserCustomItemData.java b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomItemData.java
new file mode 100644
index 000000000..ddea9937c
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomItemData.java
@@ -0,0 +1,158 @@
+/*
+ * 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.item;
+
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.api.item.custom.CustomRenderOffsets;
+import org.jetbrains.annotations.NotNull;
+
+@EqualsAndHashCode
+@ToString
+public class GeyserCustomItemData implements CustomItemData {
+    private final String name;
+    private final CustomItemOptions customItemOptions;
+    private final String displayName;
+    private final String icon;
+    private final boolean allowOffhand;
+    private final int textureSize;
+    private final CustomRenderOffsets renderOffsets;
+
+    public GeyserCustomItemData(String name,
+                                CustomItemOptions customItemOptions,
+                                String displayName,
+                                String icon,
+                                boolean allowOffhand,
+                                int textureSize,
+                                CustomRenderOffsets renderOffsets) {
+        this.name = name;
+        this.customItemOptions = customItemOptions;
+        this.displayName = displayName;
+        this.icon = icon;
+        this.allowOffhand = allowOffhand;
+        this.textureSize = textureSize;
+        this.renderOffsets = renderOffsets;
+    }
+
+    public @NotNull String name() {
+        return name;
+    }
+
+    public CustomItemOptions customItemOptions() {
+        return customItemOptions;
+    }
+
+    public @NotNull String displayName() {
+        return displayName;
+    }
+
+    public @NotNull String icon() {
+        return icon;
+    }
+
+    public boolean allowOffhand() {
+        return allowOffhand;
+    }
+
+    public int textureSize() {
+        return textureSize;
+    }
+
+    public CustomRenderOffsets renderOffsets() {
+        return renderOffsets;
+    }
+
+    public static class CustomItemDataBuilder implements Builder {
+        protected String name = null;
+        protected CustomItemOptions customItemOptions = null;
+
+        protected String displayName = null;
+        protected String icon = null;
+        protected boolean allowOffhand = true; // Bedrock doesn't give items offhand allowance unless they serve gameplay purpose, but we want to be friendly with Java
+        protected int textureSize = 16;
+        protected CustomRenderOffsets renderOffsets = null;
+
+        @Override
+        public Builder name(@NonNull String name) {
+            this.name = name;
+            return this;
+        }
+
+        @Override
+        public Builder customItemOptions(@NonNull CustomItemOptions customItemOptions) {
+            this.customItemOptions = customItemOptions;
+            return this;
+        }
+
+        @Override
+        public Builder displayName(@NonNull String displayName) {
+            this.displayName = displayName;
+            return this;
+        }
+
+        @Override
+        public Builder icon(@NonNull String icon) {
+            this.icon = icon;
+            return this;
+        }
+
+        @Override
+        public Builder allowOffhand(boolean allowOffhand) {
+            this.allowOffhand = allowOffhand;
+            return this;
+        }
+
+        @Override
+        public Builder textureSize(int textureSize) {
+            this.textureSize = textureSize;
+            return this;
+        }
+
+        @Override
+        public Builder renderOffsets(CustomRenderOffsets renderOffsets) {
+            this.renderOffsets = renderOffsets;
+            return this;
+        }
+
+        @Override
+        public CustomItemData build() {
+            if (this.name == null || this.customItemOptions == null) {
+                throw new IllegalArgumentException("Name and custom item options must be set");
+            }
+
+            if (this.displayName == null) {
+                this.displayName = this.name;
+            }
+            if (this.icon == null) {
+                this.icon = this.name;
+            }
+            return new GeyserCustomItemData(this.name, this.customItemOptions, this.displayName, this.icon, this.allowOffhand, this.textureSize, this.renderOffsets);
+        }
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/GeyserCustomItemOptions.java b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomItemOptions.java
new file mode 100644
index 000000000..dd4ae01de
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomItemOptions.java
@@ -0,0 +1,69 @@
+/*
+ * 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.item;
+
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.api.util.TriState;
+
+import java.util.OptionalInt;
+
+public record GeyserCustomItemOptions(TriState unbreakable,
+                                      OptionalInt customModelData,
+                                      OptionalInt damagePredicate) implements CustomItemOptions {
+
+    public static class CustomItemOptionsBuilder implements CustomItemOptions.Builder {
+        private TriState unbreakable = TriState.NOT_SET;
+        private OptionalInt customModelData = OptionalInt.empty();
+        private OptionalInt damagePredicate = OptionalInt.empty();
+
+        @Override
+        public Builder unbreakable(boolean unbreakable) {
+            if (unbreakable) {
+                this.unbreakable = TriState.TRUE;
+            } else {
+                this.unbreakable = TriState.FALSE;
+            }
+            return this;
+        }
+
+        @Override
+        public Builder customModelData(int customModelData) {
+            this.customModelData = OptionalInt.of(customModelData);
+            return this;
+        }
+
+        @Override
+        public Builder damagePredicate(int damagePredicate) {
+            this.damagePredicate = OptionalInt.of(damagePredicate);
+            return this;
+        }
+
+        @Override
+        public CustomItemOptions build() {
+            return new GeyserCustomItemOptions(unbreakable, customModelData, damagePredicate);
+        }
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java
new file mode 100644
index 000000000..3829db3c3
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java
@@ -0,0 +1,32 @@
+/*
+ * 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.item;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ComponentItemData;
+import com.nukkitx.protocol.bedrock.packet.StartGamePacket;
+
+public record GeyserCustomMappingData(ComponentItemData componentItemData, StartGamePacket.ItemEntry startGamePacketItemEntry, String stringId, int integerId) {
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/GeyserNonVanillaCustomItemData.java b/core/src/main/java/org/geysermc/geyser/item/GeyserNonVanillaCustomItemData.java
new file mode 100644
index 000000000..efdc1fdcf
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/GeyserNonVanillaCustomItemData.java
@@ -0,0 +1,303 @@
+/*
+ * 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.item;
+
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.api.item.custom.CustomRenderOffsets;
+import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.OptionalInt;
+import java.util.Set;
+
+@EqualsAndHashCode(callSuper = true)
+@ToString
+public final class GeyserNonVanillaCustomItemData extends GeyserCustomItemData implements NonVanillaCustomItemData {
+    private final String identifier;
+    private final int javaId;
+    private final int stackSize;
+    private final int maxDamage;
+    private final String toolType;
+    private final String toolTier;
+    private final String armorType;
+    private final int protectionValue;
+    private final String translationString;
+    private final Set<String> repairMaterials;
+    private final OptionalInt creativeCategory;
+    private final String creativeGroup;
+    private final boolean isHat;
+    private final boolean isTool;
+
+    public GeyserNonVanillaCustomItemData(NonVanillaCustomItemDataBuilder builder) {
+        super(builder.name, builder.customItemOptions, builder.displayName, builder.icon, builder.allowOffhand,
+                builder.textureSize, builder.renderOffsets);
+
+        this.identifier = builder.identifier;
+        this.javaId = builder.javaId;
+        this.stackSize = builder.stackSize;
+        this.maxDamage = builder.maxDamage;
+        this.toolType = builder.toolType;
+        this.toolTier = builder.toolTier;
+        this.armorType = builder.armorType;
+        this.protectionValue = builder.protectionValue;
+        this.translationString = builder.translationString;
+        this.repairMaterials = builder.repairMaterials;
+        this.creativeCategory = builder.creativeCategory;
+        this.creativeGroup = builder.creativeGroup;
+        this.isHat = builder.hat;
+        this.isTool = builder.tool;
+    }
+
+    @Override
+    public @NotNull String identifier() {
+        return identifier;
+    }
+
+    @Override
+    public int javaId() {
+        return javaId;
+    }
+
+    @Override
+    public int stackSize() {
+        return stackSize;
+    }
+
+    @Override
+    public int maxDamage() {
+        return maxDamage;
+    }
+
+    @Override
+    public String toolType() {
+        return toolType;
+    }
+
+    @Override
+    public String toolTier() {
+        return toolTier;
+    }
+
+    @Override
+    public @Nullable String armorType() {
+        return armorType;
+    }
+
+    @Override
+    public int protectionValue() {
+        return protectionValue;
+    }
+
+    @Override
+    public String translationString() {
+        return translationString;
+    }
+
+    @Override
+    public Set<String> repairMaterials() {
+        return repairMaterials;
+    }
+
+    @Override
+    public @NotNull OptionalInt creativeCategory() {
+        return creativeCategory;
+    }
+
+    @Override
+    public String creativeGroup() {
+        return creativeGroup;
+    }
+
+    @Override
+    public boolean isHat() {
+        return isHat;
+    }
+
+    @Override
+    public boolean isTool() {
+        return isTool;
+    }
+
+    public static class NonVanillaCustomItemDataBuilder extends GeyserCustomItemData.CustomItemDataBuilder implements NonVanillaCustomItemData.Builder {
+        private String identifier = null;
+        private int javaId = -1;
+
+        private int stackSize = 64;
+
+        private int maxDamage = 0;
+
+        private String toolType = null;
+        private String toolTier = null;
+
+        private String armorType = null;
+        private int protectionValue = 0;
+
+        private String translationString;
+
+        private Set<String> repairMaterials;
+
+        private OptionalInt creativeCategory = OptionalInt.empty();
+        private String creativeGroup = null;
+
+        private boolean hat = false;
+        private boolean tool = false;
+
+        @Override
+        public NonVanillaCustomItemData.Builder name(@NonNull String name) {
+            return (NonVanillaCustomItemData.Builder) super.name(name);
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder customItemOptions(@NonNull CustomItemOptions customItemOptions) {
+            //Do nothing, as that value won't be read
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder allowOffhand(boolean allowOffhand) {
+            return (NonVanillaCustomItemData.Builder) super.allowOffhand(allowOffhand);
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder displayName(@NonNull String displayName) {
+            return (NonVanillaCustomItemData.Builder) super.displayName(displayName);
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder icon(@NonNull String icon) {
+            return (NonVanillaCustomItemData.Builder) super.icon(icon);
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder textureSize(int textureSize) {
+            return (NonVanillaCustomItemData.Builder) super.textureSize(textureSize);
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder renderOffsets(CustomRenderOffsets renderOffsets) {
+            return (NonVanillaCustomItemData.Builder) super.renderOffsets(renderOffsets);
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder identifier(@NonNull String identifier) {
+            this.identifier = identifier;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder javaId(int javaId) {
+            this.javaId = javaId;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder stackSize(int stackSize) {
+            this.stackSize = stackSize;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder maxDamage(int maxDamage) {
+            this.maxDamage = maxDamage;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder toolType(@Nullable String toolType) {
+            this.toolType = toolType;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder toolTier(@Nullable String toolTier) {
+            this.toolTier = toolTier;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder armorType(@Nullable String armorType) {
+            this.armorType = armorType;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder protectionValue(int protectionValue) {
+            this.protectionValue = protectionValue;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder translationString(@Nullable String translationString) {
+            this.translationString = translationString;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder repairMaterials(@Nullable Set<String> repairMaterials) {
+            this.repairMaterials = repairMaterials;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder creativeCategory(int creativeCategory) {
+            this.creativeCategory = OptionalInt.of(creativeCategory);
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder creativeGroup(@Nullable String creativeGroup) {
+            this.creativeGroup = creativeGroup;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder hat(boolean isHat) {
+            this.hat = isHat;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData.Builder tool(boolean isTool) {
+            this.tool = isTool;
+            return this;
+        }
+
+        @Override
+        public NonVanillaCustomItemData build() {
+            if (identifier == null || javaId == -1) {
+                throw new IllegalArgumentException("Identifier and javaId must be set");
+            }
+
+            super.customItemOptions(CustomItemOptions.builder().build());
+            super.build();
+            return new GeyserNonVanillaCustomItemData(this);
+        }
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/components/ToolBreakSpeedsUtils.java b/core/src/main/java/org/geysermc/geyser/item/components/ToolBreakSpeedsUtils.java
new file mode 100644
index 000000000..6330043e5
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/components/ToolBreakSpeedsUtils.java
@@ -0,0 +1,174 @@
+/*
+ * 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.item.components;
+
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtType;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ToolBreakSpeedsUtils {
+    public static int toolTierToSpeed(String toolTier) {
+        ToolTier tier = ToolTier.getByName(toolTier);
+        if (tier != null) {
+            return tier.getSpeed();
+        }
+
+        return 0;
+    }
+
+    private static NbtMap createTagBreakSpeed(int speed, String... tags) {
+        StringBuilder builder = new StringBuilder("query.any_tag('");
+        builder.append(tags[0]);
+        for (int i = 1; i < tags.length; i++) {
+            builder.append("', '").append(tags[i]);
+        }
+        builder.append("')");
+
+        return NbtMap.builder()
+                .putCompound("block", NbtMap.builder()
+                        .putString("tags", builder.toString())
+                        .build())
+                .putCompound("on_dig", NbtMap.builder()
+                        .putCompound("condition", NbtMap.builder()
+                                .putString("expression", "")
+                                .putInt("version", -1)
+                                .build())
+                        .putString("event", "tool_durability")
+                        .putString("target", "self")
+                        .build())
+                .putInt("speed", speed)
+                .build();
+    }
+
+    private static NbtMap createBreakSpeed(int speed, String block) {
+        return NbtMap.builder()
+                .putCompound("block", NbtMap.builder()
+                        .putString("name", block).build())
+                .putCompound("on_dig", NbtMap.builder()
+                        .putCompound("condition", NbtMap.builder()
+                                .putString("expression", "")
+                                .putInt("version", -1)
+                                .build())
+                        .putString("event", "tool_durability")
+                        .putString("target", "self")
+                        .build())
+                .putInt("speed", speed)
+                .build();
+    }
+
+    private static NbtMap createDigger(List<NbtMap> speeds) {
+        return NbtMap.builder()
+                .putList("destroy_speeds", NbtType.COMPOUND, speeds)
+                .putCompound("on_dig", NbtMap.builder()
+                        .putCompound("condition", NbtMap.builder()
+                                .putString("expression", "")
+                                .putInt("version", -1)
+                                .build())
+                        .putString("event", "tool_durability")
+                        .putString("target", "self")
+                        .build())
+                .putBoolean("use_efficiency", true)
+                .build();
+    }
+
+    public static NbtMap getAxeDigger(int speed) {
+        List<NbtMap> speeds = new ArrayList<>();
+        speeds.add(createTagBreakSpeed(speed, "wood", "pumpkin", "plant"));
+
+        return createDigger(speeds);
+    }
+
+    public static NbtMap getPickaxeDigger(int speed, String toolTier) {
+        List<NbtMap> speeds = new ArrayList<>();
+        if (toolTier.equals(ToolTier.DIAMOND.toString()) || toolTier.equals(ToolTier.NETHERITE.toString())) {
+            speeds.add(createTagBreakSpeed(speed, "iron_pick_diggable", "diamond_pick_diggable"));
+        } else {
+            speeds.add(createTagBreakSpeed(speed, "iron_pick_diggable"));
+        }
+        speeds.add(createTagBreakSpeed(speed, "stone", "metal", "rail", "mob_spawner"));
+
+        return createDigger(speeds);
+    }
+
+    public static NbtMap getShovelDigger(int speed) {
+        List<NbtMap> speeds = new ArrayList<>();
+        speeds.add(createTagBreakSpeed(speed, "dirt", "sand", "gravel", "grass", "snow"));
+
+        return createDigger(speeds);
+    }
+
+    public static NbtMap getSwordDigger(int speed) {
+        List<NbtMap> speeds = new ArrayList<>();
+        speeds.add(createBreakSpeed(speed, "minecraft:web"));
+        speeds.add(createBreakSpeed(speed, "minecraft:bamboo"));
+
+        return createDigger(speeds);
+    }
+
+    public static NbtMap getHoeDigger(int speed) {
+        List<NbtMap> speeds = new ArrayList<>();
+        speeds.add(createBreakSpeed(speed, "minecraft:leaves"));
+        speeds.add(createBreakSpeed(speed, "minecraft:leaves2"));
+        speeds.add(createBreakSpeed(speed, "minecraft:azalea_leaves"));
+        speeds.add(createBreakSpeed(speed, "minecraft:azalea_leaves_flowered"));
+
+        speeds.add(createBreakSpeed(speed, "minecraft:sculk"));
+        speeds.add(createBreakSpeed(speed, "minecraft:sculk_catalyst"));
+        speeds.add(createBreakSpeed(speed, "minecraft:sculk_sensor"));
+        speeds.add(createBreakSpeed(speed, "minecraft:sculk_shrieker"));
+        speeds.add(createBreakSpeed(speed, "minecraft:sculk_vein"));
+
+        speeds.add(createBreakSpeed(speed, "minecraft:nether_wart_block"));
+        speeds.add(createBreakSpeed(speed, "minecraft:warped_wart_block"));
+
+        speeds.add(createBreakSpeed(speed, "minecraft:hay_block"));
+        speeds.add(createBreakSpeed(speed, "minecraft:moss_block"));
+        speeds.add(createBreakSpeed(speed, "minecraft:shroomlight"));
+        speeds.add(createBreakSpeed(speed, "minecraft:sponge"));
+        speeds.add(createBreakSpeed(speed, "minecraft:target"));
+
+        return createDigger(speeds);
+    }
+
+    public static NbtMap getShearsDigger(int speed) {
+        List<NbtMap> speeds = new ArrayList<>();
+        speeds.add(createBreakSpeed(speed, "minecraft:web"));
+
+        speeds.add(createBreakSpeed(speed, "minecraft:leaves"));
+        speeds.add(createBreakSpeed(speed, "minecraft:leaves2"));
+        speeds.add(createBreakSpeed(speed, "minecraft:azalea_leaves"));
+        speeds.add(createBreakSpeed(speed, "minecraft:azalea_leaves_flowered"));
+
+        speeds.add(createBreakSpeed(speed, "minecraft:wool"));
+
+        speeds.add(createBreakSpeed(speed, "minecraft:glow_lichen"));
+        speeds.add(createBreakSpeed(speed, "minecraft:vine"));
+
+        return createDigger(speeds);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/components/ToolTier.java b/core/src/main/java/org/geysermc/geyser/item/components/ToolTier.java
new file mode 100644
index 000000000..37e581682
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/components/ToolTier.java
@@ -0,0 +1,66 @@
+/*
+ * 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.item.components;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+import java.util.Locale;
+
+public enum ToolTier {
+    WOODEN(2),
+    STONE(4),
+    IRON(6),
+    GOLDEN(12),
+    DIAMOND(8),
+    NETHERITE(9);
+
+    public static final ToolTier[] VALUES = values();
+
+    private final int speed;
+
+    ToolTier(int speed) {
+        this.speed = speed;
+    }
+
+    public int getSpeed() {
+        return speed;
+    }
+
+    @Override
+    public String toString() {
+        return this.name().toLowerCase(Locale.ROOT);
+    }
+
+    public static ToolTier getByName(@NonNull String name) {
+        String upperCase = name.toUpperCase(Locale.ROOT);
+        for (ToolTier tier : VALUES) {
+            if (tier.name().equals(upperCase)) {
+                return tier;
+            }
+        }
+        return null;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/components/WearableSlot.java b/core/src/main/java/org/geysermc/geyser/item/components/WearableSlot.java
new file mode 100644
index 000000000..a4479f871
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/components/WearableSlot.java
@@ -0,0 +1,47 @@
+/*
+ * 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.item.components;
+
+import com.nukkitx.nbt.NbtMap;
+
+import java.util.Locale;
+
+public enum WearableSlot {
+    HEAD,
+    CHEST,
+    LEGS,
+    FEET;
+
+    private final NbtMap slotNbt;
+
+    WearableSlot() {
+        this.slotNbt = NbtMap.builder().putString("slot", "slot.armor." + this.name().toLowerCase(Locale.ROOT)).build();
+    }
+
+    public NbtMap getSlotNbt() {
+        return slotNbt;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java b/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java
new file mode 100644
index 000000000..5878f5cc7
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java
@@ -0,0 +1,40 @@
+/*
+ * 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.item.exception;
+
+public class InvalidCustomMappingsFileException extends Exception {
+    public InvalidCustomMappingsFileException(Throwable cause) {
+        super(cause);
+    }
+
+    public InvalidCustomMappingsFileException(String message) {
+        super(message);
+    }
+
+    public InvalidCustomMappingsFileException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/mappings/MappingsConfigReader.java b/core/src/main/java/org/geysermc/geyser/item/mappings/MappingsConfigReader.java
new file mode 100644
index 000000000..eaf07c382
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/mappings/MappingsConfigReader.java
@@ -0,0 +1,98 @@
+/*
+ * 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.item.mappings;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.item.mappings.versions.MappingsReader;
+import org.geysermc.geyser.item.mappings.versions.MappingsReader_v1;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.function.BiConsumer;
+
+public class MappingsConfigReader {
+    private final Int2ObjectMap<MappingsReader> mappingReaders = new Int2ObjectOpenHashMap<>();
+    private final Path customMappingsDirectory = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("custom_mappings");
+
+    public MappingsConfigReader() {
+        this.mappingReaders.put(1, new MappingsReader_v1());
+    }
+
+    public Path[] getCustomMappingsFiles() {
+        try {
+            return Files.walk(this.customMappingsDirectory)
+                    .filter(child -> child.toString().endsWith(".json"))
+                    .toArray(Path[]::new);
+        } catch (IOException e) {
+            return new Path[0];
+        }
+    }
+
+    public void loadMappingsFromJson(BiConsumer<String, CustomItemData> consumer) {
+        Path customMappingsDirectory = this.customMappingsDirectory;
+        if (!Files.exists(customMappingsDirectory)) {
+            try {
+                Files.createDirectories(customMappingsDirectory);
+            } catch (IOException e) {
+                GeyserImpl.getInstance().getLogger().error("Failed to create custom mappings directory", e);
+                return;
+            }
+        }
+
+        Path[] mappingsFiles = this.getCustomMappingsFiles();
+        for (Path mappingsFile : mappingsFiles) {
+            this.readMappingsFromJson(mappingsFile, consumer);
+        }
+    }
+
+    public void readMappingsFromJson(Path file, BiConsumer<String, CustomItemData> consumer) {
+        JsonNode mappingsRoot;
+        try {
+            mappingsRoot = GeyserImpl.JSON_MAPPER.readTree(file.toFile());
+        } catch (IOException e) {
+            GeyserImpl.getInstance().getLogger().error("Failed to read custom mapping file: " + file, e);
+            return;
+        }
+
+        if (!mappingsRoot.has("format_version")) {
+            GeyserImpl.getInstance().getLogger().error("Mappings file " + file + " is missing the format version field!");
+            return;
+        }
+
+        int formatVersion = mappingsRoot.get("format_version").asInt();
+        if (!this.mappingReaders.containsKey(formatVersion)) {
+            GeyserImpl.getInstance().getLogger().error("Mappings file " + file + " has an unknown format version: " + formatVersion);
+            return;
+        }
+
+        this.mappingReaders.get(formatVersion).readMappings(file, mappingsRoot, consumer);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/mappings/versions/MappingsReader.java b/core/src/main/java/org/geysermc/geyser/item/mappings/versions/MappingsReader.java
new file mode 100644
index 000000000..ef553f488
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/mappings/versions/MappingsReader.java
@@ -0,0 +1,93 @@
+/*
+ * 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.item.mappings.versions;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.CustomRenderOffsets;
+import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException;
+
+import java.nio.file.Path;
+import java.util.function.BiConsumer;
+
+public abstract class MappingsReader {
+    public abstract void readMappings(Path file, JsonNode mappingsRoot, BiConsumer<String, CustomItemData> consumer);
+
+    public abstract CustomItemData readItemMappingEntry(JsonNode node) throws InvalidCustomMappingsFileException;
+
+    protected CustomRenderOffsets fromJsonNode(JsonNode node) {
+        if (node == null || !node.isObject()) {
+            return null;
+        }
+
+        return new CustomRenderOffsets(
+                getHandOffsets(node, "main_hand"),
+                getHandOffsets(node, "off_hand")
+        );
+    }
+
+    protected CustomRenderOffsets.Hand getHandOffsets(JsonNode node, String hand) {
+        JsonNode tmpNode = node.get(hand);
+        if (tmpNode == null || !tmpNode.isObject()) {
+            return null;
+        }
+
+        return new CustomRenderOffsets.Hand(
+                getPerspectiveOffsets(tmpNode, "first_person"),
+                getPerspectiveOffsets(tmpNode, "third_person")
+        );
+    }
+
+    protected CustomRenderOffsets.Offset getPerspectiveOffsets(JsonNode node, String perspective) {
+        JsonNode tmpNode = node.get(perspective);
+        if (tmpNode == null || !tmpNode.isObject()) {
+            return null;
+        }
+
+        return new CustomRenderOffsets.Offset(
+                getOffsetXYZ(tmpNode, "position"),
+                getOffsetXYZ(tmpNode, "rotation"),
+                getOffsetXYZ(tmpNode, "scale")
+        );
+    }
+
+    protected CustomRenderOffsets.OffsetXYZ getOffsetXYZ(JsonNode node, String offsetType) {
+        JsonNode tmpNode = node.get(offsetType);
+        if (tmpNode == null || !tmpNode.isObject()) {
+            return null;
+        }
+
+        if (!tmpNode.has("x") || !tmpNode.has("y") || !tmpNode.has("z")) {
+            return null;
+        }
+
+        return new CustomRenderOffsets.OffsetXYZ(
+                tmpNode.get("x").floatValue(),
+                tmpNode.get("y").floatValue(),
+                tmpNode.get("z").floatValue()
+        );
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/item/mappings/versions/MappingsReader_v1.java b/core/src/main/java/org/geysermc/geyser/item/mappings/versions/MappingsReader_v1.java
new file mode 100644
index 000000000..217ff844e
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/item/mappings/versions/MappingsReader_v1.java
@@ -0,0 +1,123 @@
+/*
+ * 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.item.mappings.versions;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException;
+
+import java.nio.file.Path;
+import java.util.function.BiConsumer;
+
+public class MappingsReader_v1 extends MappingsReader {
+    @Override
+    public void readMappings(Path file, JsonNode mappingsRoot, BiConsumer<String, CustomItemData> consumer) {
+        this.readItemMappings(file, mappingsRoot, consumer);
+    }
+
+    public void readItemMappings(Path file, JsonNode mappingsRoot, BiConsumer<String, CustomItemData> consumer) {
+        JsonNode itemsNode = mappingsRoot.get("items");
+
+        if (itemsNode != null && itemsNode.isObject()) {
+            itemsNode.fields().forEachRemaining(entry -> {
+                if (entry.getValue().isArray()) {
+                    entry.getValue().forEach(data -> {
+                        try {
+                            CustomItemData customItemData = this.readItemMappingEntry(data);
+                            consumer.accept(entry.getKey(), customItemData);
+                        } catch (InvalidCustomMappingsFileException e) {
+                            GeyserImpl.getInstance().getLogger().error("Error in custom mapping file: " + file.toString(), e);
+                        }
+                    });
+                }
+            });
+        }
+    }
+
+    private CustomItemOptions readItemCustomItemOptions(JsonNode node) {
+        CustomItemOptions.Builder customItemOptions = CustomItemOptions.builder();
+
+        JsonNode customModelData = node.get("custom_model_data");
+        if (customModelData != null && customModelData.isInt()) {
+            customItemOptions.customModelData(customModelData.asInt());
+        }
+
+        JsonNode damagePredicate = node.get("damage_predicate");
+        if (damagePredicate != null && damagePredicate.isInt()) {
+            customItemOptions.damagePredicate(damagePredicate.asInt());
+        }
+
+        JsonNode unbreakable = node.get("unbreakable");
+        if (unbreakable != null && unbreakable.isBoolean()) {
+            customItemOptions.unbreakable(unbreakable.asBoolean());
+        }
+
+        return customItemOptions.build();
+    }
+
+    @Override
+    public CustomItemData readItemMappingEntry(JsonNode node) throws InvalidCustomMappingsFileException {
+        if (node == null || !node.isObject()) {
+            throw new InvalidCustomMappingsFileException("Invalid item mappings entry");
+        }
+
+        String name = node.get("name").asText();
+        if (name == null || name.isEmpty()) {
+            throw new InvalidCustomMappingsFileException("An item entry has no name");
+        }
+
+        CustomItemData.Builder customItemData = CustomItemData.builder()
+                .name(name)
+                .customItemOptions(this.readItemCustomItemOptions(node));
+
+        //The next entries are optional
+        if (node.has("display_name")) {
+            customItemData.displayName(node.get("display_name").asText());
+        }
+
+        if (node.has("icon")) {
+            customItemData.icon(node.get("icon").asText());
+        }
+
+        if (node.has("allow_offhand")) {
+            customItemData.allowOffhand(node.get("allow_offhand").asBoolean());
+        }
+
+        if (node.has("texture_size")) {
+            customItemData.textureSize(node.get("texture_size").asInt());
+        }
+
+        if (node.has("render_offsets")) {
+            JsonNode tmpNode = node.get("render_offsets");
+
+            customItemData.renderOffsets(fromJsonNode(tmpNode));
+        }
+
+        return customItemData.build();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/level/block/BlockStateValues.java b/core/src/main/java/org/geysermc/geyser/level/block/BlockStateValues.java
index e4772df80..58cbce77f 100644
--- a/core/src/main/java/org/geysermc/geyser/level/block/BlockStateValues.java
+++ b/core/src/main/java/org/geysermc/geyser/level/block/BlockStateValues.java
@@ -29,11 +29,11 @@ import com.fasterxml.jackson.databind.JsonNode;
 import it.unimi.dsi.fastutil.ints.*;
 import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
-import org.geysermc.geyser.translator.level.block.entity.PistonBlockEntityTranslator;
-import org.geysermc.geyser.registry.BlockRegistries;
-import org.geysermc.geyser.registry.type.BlockMapping;
 import org.geysermc.geyser.level.physics.Direction;
 import org.geysermc.geyser.level.physics.PistonBehavior;
+import org.geysermc.geyser.registry.BlockRegistries;
+import org.geysermc.geyser.registry.type.BlockMapping;
+import org.geysermc.geyser.translator.level.block.entity.PistonBlockEntityTranslator;
 import org.geysermc.geyser.util.collection.FixedInt2ByteMap;
 import org.geysermc.geyser.util.collection.FixedInt2IntMap;
 import org.geysermc.geyser.util.collection.LecternHasBookMap;
diff --git a/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java b/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java
index 108982a32..d6913d6c0 100644
--- a/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java
+++ b/core/src/main/java/org/geysermc/geyser/level/physics/BoundingBox.java
@@ -26,7 +26,9 @@
 package org.geysermc.geyser.level.physics;
 
 import com.nukkitx.math.vector.Vector3d;
-import lombok.*;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.SneakyThrows;
 
 @Data
 @AllArgsConstructor
diff --git a/core/src/main/java/org/geysermc/geyser/level/physics/CollisionManager.java b/core/src/main/java/org/geysermc/geyser/level/physics/CollisionManager.java
index a91c2b083..2a830cd70 100644
--- a/core/src/main/java/org/geysermc/geyser/level/physics/CollisionManager.java
+++ b/core/src/main/java/org/geysermc/geyser/level/physics/CollisionManager.java
@@ -33,15 +33,15 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.packet.MovePlayerPacket;
 import lombok.Getter;
 import lombok.Setter;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinitions;
+import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
+import org.geysermc.geyser.level.block.BlockPositionIterator;
+import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.cache.PistonCache;
 import org.geysermc.geyser.translator.collision.BlockCollision;
 import org.geysermc.geyser.translator.collision.ScaffoldingCollision;
-import org.geysermc.geyser.level.block.BlockStateValues;
-import org.geysermc.geyser.level.block.BlockPositionIterator;
 import org.geysermc.geyser.util.BlockUtils;
 
 import java.text.DecimalFormat;
diff --git a/core/src/main/java/org/geysermc/geyser/network/ConnectorServerEventHandler.java b/core/src/main/java/org/geysermc/geyser/network/ConnectorServerEventHandler.java
index 1c6f9db88..c9a3201c1 100644
--- a/core/src/main/java/org/geysermc/geyser/network/ConnectorServerEventHandler.java
+++ b/core/src/main/java/org/geysermc/geyser/network/ConnectorServerEventHandler.java
@@ -28,18 +28,19 @@ package org.geysermc.geyser.network;
 import com.nukkitx.protocol.bedrock.BedrockPong;
 import com.nukkitx.protocol.bedrock.BedrockServerEventHandler;
 import com.nukkitx.protocol.bedrock.BedrockServerSession;
+import com.nukkitx.protocol.bedrock.v554.Bedrock_v554;
 import io.netty.buffer.ByteBuf;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.DefaultEventLoopGroup;
 import io.netty.channel.socket.DatagramPacket;
 import io.netty.util.concurrent.DefaultThreadFactory;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.ping.GeyserPingInfo;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.text.MessageTranslator;
+import org.geysermc.geyser.ping.GeyserPingInfo;
 import org.geysermc.geyser.ping.IGeyserPingPassthrough;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
+import org.geysermc.geyser.translator.text.MessageTranslator;
 
 import javax.annotation.Nonnull;
 import java.net.InetSocketAddress;
@@ -52,7 +53,7 @@ public class ConnectorServerEventHandler implements BedrockServerEventHandler {
     /*
     The following constants are all used to ensure the ping does not reach a length where it is unparsable by the Bedrock client
      */
-    private static final int MINECRAFT_VERSION_BYTES_LENGTH = MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getMinecraftVersion().getBytes(StandardCharsets.UTF_8).length;
+    private static final int MINECRAFT_VERSION_BYTES_LENGTH = GameProtocol.DEFAULT_BEDROCK_CODEC.getMinecraftVersion().getBytes(StandardCharsets.UTF_8).length;
     private static final int BRAND_BYTES_LENGTH = GeyserImpl.NAME.getBytes(StandardCharsets.UTF_8).length;
     /**
      * The MOTD, sub-MOTD and Minecraft version ({@link #MINECRAFT_VERSION_BYTES_LENGTH}) combined cannot reach this length.
@@ -108,9 +109,9 @@ public class ConnectorServerEventHandler implements BedrockServerEventHandler {
         pong.setEdition("MCPE");
         pong.setGameType("Survival"); // Can only be Survival or Creative as of 1.16.210.59
         pong.setNintendoLimited(false);
-        pong.setProtocolVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion());
-        pong.setVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getMinecraftVersion()); // Required to not be empty as of 1.16.210.59. Can only contain . and numbers.
-        pong.setIpv4Port(config.getBedrock().getPort());
+        pong.setProtocolVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion());
+        pong.setVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getMinecraftVersion()); // Required to not be empty as of 1.16.210.59. Can only contain . and numbers.
+        pong.setIpv4Port(config.getBedrock().port());
 
         if (config.isPassthroughMotd() && pingInfo != null && pingInfo.getDescription() != null) {
             String[] motd = MessageTranslator.convertMessageLenient(pingInfo.getDescription()).split("\n");
@@ -120,8 +121,8 @@ public class ConnectorServerEventHandler implements BedrockServerEventHandler {
             pong.setMotd(mainMotd.trim());
             pong.setSubMotd(subMotd.trim()); // Trimmed to shift it to the left, prevents the universe from collapsing on us just because we went 2 characters over the text box's limit.
         } else {
-            pong.setMotd(config.getBedrock().getMotd1());
-            pong.setSubMotd(config.getBedrock().getMotd2());
+            pong.setMotd(config.getBedrock().primaryMotd());
+            pong.setSubMotd(config.getBedrock().secondaryMotd());
         }
 
         if (config.isPassthroughPlayerCounts() && pingInfo != null) {
@@ -171,7 +172,7 @@ public class ConnectorServerEventHandler implements BedrockServerEventHandler {
     @Override
     public void onSessionCreation(@Nonnull BedrockServerSession bedrockServerSession) {
         try {
-            bedrockServerSession.setPacketCodec(MinecraftProtocol.DEFAULT_BEDROCK_CODEC);
+            bedrockServerSession.setPacketCodec(Bedrock_v554.V554_CODEC); // Has the RequestNetworkSettingsPacket
             bedrockServerSession.setLogging(true);
             bedrockServerSession.setCompressionLevel(geyser.getConfig().getBedrock().getCompressionLevel());
             bedrockServerSession.setPacketHandler(new UpstreamPacketHandler(geyser, new GeyserSession(geyser, bedrockServerSession, eventLoopGroup.next())));
diff --git a/core/src/main/java/org/geysermc/geyser/network/MinecraftProtocol.java b/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java
similarity index 89%
rename from core/src/main/java/org/geysermc/geyser/network/MinecraftProtocol.java
rename to core/src/main/java/org/geysermc/geyser/network/GameProtocol.java
index cec5c5ce6..f31d800c7 100644
--- a/core/src/main/java/org/geysermc/geyser/network/MinecraftProtocol.java
+++ b/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java
@@ -31,6 +31,7 @@ import com.nukkitx.protocol.bedrock.BedrockPacketCodec;
 import com.nukkitx.protocol.bedrock.v527.Bedrock_v527;
 import com.nukkitx.protocol.bedrock.v534.Bedrock_v534;
 import com.nukkitx.protocol.bedrock.v544.Bedrock_v544;
+import com.nukkitx.protocol.bedrock.v554.Bedrock_v554;
 import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.ArrayList;
@@ -40,7 +41,7 @@ import java.util.StringJoiner;
 /**
  * Contains information about the supported protocols in Geyser.
  */
-public final class MinecraftProtocol {
+public final class GameProtocol {
     /**
      * Default Bedrock codec that should act as a fallback. Should represent the latest available
      * release of the game that Geyser supports.
@@ -71,6 +72,7 @@ public final class MinecraftProtocol {
         SUPPORTED_BEDROCK_CODECS.add(DEFAULT_BEDROCK_CODEC.toBuilder()
                 .minecraftVersion("1.19.21/1.19.22")
                 .build());
+        SUPPORTED_BEDROCK_CODECS.add(Bedrock_v554.V554_CODEC);
     }
 
     /**
@@ -93,6 +95,10 @@ public final class MinecraftProtocol {
         return session.getUpstream().getProtocolVersion() >= Bedrock_v534.V534_CODEC.getProtocolVersion();
     }
 
+    public static boolean supports1_19_30(GeyserSession session) {
+        return session.getUpstream().getProtocolVersion() >= Bedrock_v554.V554_CODEC.getProtocolVersion();
+    }
+
     /**
      * Gets the {@link PacketCodec} for Minecraft: Java Edition.
      *
@@ -120,6 +126,15 @@ public final class MinecraftProtocol {
         return DEFAULT_JAVA_CODEC.getProtocolVersion();
     }
 
+    /**
+     * Gets the supported Minecraft: Java Edition version.
+     *
+     * @return the supported Minecraft: Java Edition version
+     */
+    public static String getJavaMinecraftVersion() {
+        return DEFAULT_JAVA_CODEC.getMinecraftVersion();
+    }
+
     /**
      * @return a string showing all supported Bedrock versions for this Geyser instance
      */
@@ -144,6 +159,6 @@ public final class MinecraftProtocol {
         return joiner.toString();
     }
 
-    private MinecraftProtocol() {
+    private GameProtocol() {
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/network/LoggingPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/LoggingPacketHandler.java
index b0b707ee0..8d2db081a 100644
--- a/core/src/main/java/org/geysermc/geyser/network/LoggingPacketHandler.java
+++ b/core/src/main/java/org/geysermc/geyser/network/LoggingPacketHandler.java
@@ -856,4 +856,18 @@ public class LoggingPacketHandler implements BedrockPacketHandler {
     public boolean handle(FilterTextPacket packet) {
         return defaultHandler(packet);
     }
+
+    // 1.19.0 new packet
+
+    @Override
+    public boolean handle(RequestAbilityPacket packet) {
+        return defaultHandler(packet);
+    }
+
+    // 1.19.30 new packet
+
+    @Override
+    public boolean handle(RequestNetworkSettingsPacket packet) {
+        return defaultHandler(packet);
+    }
 }
\ No newline at end of file
diff --git a/core/src/main/java/org/geysermc/geyser/network/QueryPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/QueryPacketHandler.java
index 0caa6fac7..d7daa9260 100644
--- a/core/src/main/java/org/geysermc/geyser/network/QueryPacketHandler.java
+++ b/core/src/main/java/org/geysermc/geyser/network/QueryPacketHandler.java
@@ -153,7 +153,7 @@ public class QueryPacketHandler {
             String[] javaMotd = MessageTranslator.convertMessageLenient(pingInfo.getDescription()).split("\n");
             motd = javaMotd[0].trim(); // First line of the motd.
         } else {
-            motd = geyser.getConfig().getBedrock().getMotd1();
+            motd = geyser.getConfig().getBedrock().primaryMotd();
         }
 
         // If passthrough player counts is enabled lets get players from the server
@@ -177,13 +177,13 @@ public class QueryPacketHandler {
         gameData.put("hostname", motd);
         gameData.put("gametype", "SMP");
         gameData.put("game_id", "MINECRAFT");
-        gameData.put("version", GeyserImpl.NAME + " (" + GeyserImpl.GIT_VERSION + ") " + MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getMinecraftVersion());
+        gameData.put("version", GeyserImpl.NAME + " (" + GeyserImpl.GIT_VERSION + ") " + GameProtocol.DEFAULT_BEDROCK_CODEC.getMinecraftVersion());
         gameData.put("plugins", "");
         gameData.put("map", map);
         gameData.put("numplayers", currentPlayerCount);
         gameData.put("maxplayers", maxPlayerCount);
-        gameData.put("hostport", String.valueOf(geyser.getConfig().getBedrock().getPort()));
-        gameData.put("hostip", geyser.getConfig().getBedrock().getAddress());
+        gameData.put("hostport", String.valueOf(geyser.getConfig().getBedrock().port()));
+        gameData.put("hostip", geyser.getConfig().getBedrock().address());
 
         try {
             writeString(query, "GeyserMC");
diff --git a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java
index b4c4ae471..c2a91fd75 100644
--- a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java
+++ b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java
@@ -28,9 +28,11 @@ package org.geysermc.geyser.network;
 import com.nukkitx.protocol.bedrock.BedrockPacket;
 import com.nukkitx.protocol.bedrock.BedrockPacketCodec;
 import com.nukkitx.protocol.bedrock.data.ExperimentData;
+import com.nukkitx.protocol.bedrock.data.PacketCompressionAlgorithm;
 import com.nukkitx.protocol.bedrock.data.ResourcePackType;
 import com.nukkitx.protocol.bedrock.packet.*;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
 import org.geysermc.geyser.pack.ResourcePack;
 import org.geysermc.geyser.pack.ResourcePackManifest;
@@ -38,7 +40,6 @@ import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.PendingMicrosoftAuthentication;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.LoginEncryptionUtils;
 import org.geysermc.geyser.util.MathUtils;
@@ -61,6 +62,46 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
         return translateAndDefault(packet);
     }
 
+    private boolean newProtocol = false; // TEMPORARY
+
+    private boolean setCorrectCodec(int protocolVersion) {
+        BedrockPacketCodec packetCodec = GameProtocol.getBedrockCodec(protocolVersion);
+        if (packetCodec == null) {
+            String supportedVersions = GameProtocol.getAllSupportedBedrockVersions();
+            if (protocolVersion > GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()) {
+                // Too early to determine session locale
+                session.disconnect(GeyserLocale.getLocaleStringLog("geyser.network.outdated.server", supportedVersions));
+                return false;
+            } else if (protocolVersion < GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()) {
+                session.disconnect(GeyserLocale.getLocaleStringLog("geyser.network.outdated.client", supportedVersions));
+                return false;
+            }
+        }
+
+        session.getUpstream().getSession().setPacketCodec(packetCodec);
+        return true;
+    }
+
+    @Override
+    public boolean handle(RequestNetworkSettingsPacket packet) {
+        if (setCorrectCodec(packet.getProtocolVersion())) {
+            newProtocol = true;
+        } else {
+            return true;
+        }
+
+        // New since 1.19.30 - sent before login packet
+        PacketCompressionAlgorithm algorithm = PacketCompressionAlgorithm.ZLIB;
+
+        NetworkSettingsPacket responsePacket = new NetworkSettingsPacket();
+        responsePacket.setCompressionAlgorithm(algorithm);
+        responsePacket.setCompressionThreshold(512);
+        session.sendUpstreamPacketImmediately(responsePacket);
+
+        session.getUpstream().getSession().setCompression(algorithm);
+        return true;
+    }
+
     @Override
     public boolean handle(LoginPacket loginPacket) {
         if (geyser.isShuttingDown()) {
@@ -69,21 +110,12 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
             return true;
         }
 
-        BedrockPacketCodec packetCodec = MinecraftProtocol.getBedrockCodec(loginPacket.getProtocolVersion());
-        if (packetCodec == null) {
-            String supportedVersions = MinecraftProtocol.getAllSupportedBedrockVersions();
-            if (loginPacket.getProtocolVersion() > MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()) {
-                // Too early to determine session locale
-                session.disconnect(GeyserLocale.getLocaleStringLog("geyser.network.outdated.server", supportedVersions));
-                return true;
-            } else if (loginPacket.getProtocolVersion() < MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()) {
-                session.disconnect(GeyserLocale.getLocaleStringLog("geyser.network.outdated.client", supportedVersions));
+        if (!newProtocol) {
+            if (!setCorrectCodec(loginPacket.getProtocolVersion())) { // REMOVE WHEN ONLY 1.19.30 IS SUPPORTED OR 1.20
                 return true;
             }
         }
 
-        session.getUpstream().getSession().setPacketCodec(packetCodec);
-
         // Set the block translation based off of version
         session.setBlockMappings(BlockRegistries.BLOCKS.forVersion(loginPacket.getProtocolVersion()));
         session.setItemMappings(Registries.ITEMS.forVersion(loginPacket.getProtocolVersion()));
@@ -111,7 +143,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
         resourcePacksInfo.setForcedToAccept(GeyserImpl.getInstance().getConfig().isForceResourcePacks());
         session.sendUpstreamPacket(resourcePacksInfo);
 
-        GeyserLocale.loadGeyserLocale(session.getLocale());
+        GeyserLocale.loadGeyserLocale(session.locale());
         return true;
     }
 
@@ -119,7 +151,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
     public boolean handle(ResourcePackClientResponsePacket packet) {
         switch (packet.getStatus()) {
             case COMPLETED:
-                if (geyser.getConfig().getRemote().getAuthType() != AuthType.ONLINE) {
+                if (geyser.getConfig().getRemote().authType() != AuthType.ONLINE) {
                     session.authenticate(session.getAuthData().name());
                 } else if (!couldLoginUserByName(session.getAuthData().name())) {
                     // We must spawn the white world
@@ -160,7 +192,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
                     stackPacket.getResourcePacks().add(new ResourcePackStackPacket.Entry(header.getUuid().toString(), header.getVersionString(), ""));
                 }
 
-                if (session.getItemMappings().getFurnaceMinecartData() != null) {
+                if (GeyserImpl.getInstance().getConfig().isAddNonBedrockItems()) {
                     // Allow custom items to work
                     stackPacket.getExperiments().add(new ExperimentData("data_driven_items", true));
                 }
@@ -216,7 +248,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
         if (session.isLoggingIn()) {
             SetTitlePacket titlePacket = new SetTitlePacket();
             titlePacket.setType(SetTitlePacket.Type.ACTIONBAR);
-            titlePacket.setText(GeyserLocale.getPlayerLocaleString("geyser.auth.login.wait", session.getLocale()));
+            titlePacket.setText(GeyserLocale.getPlayerLocaleString("geyser.auth.login.wait", session.locale()));
             titlePacket.setFadeInTime(0);
             titlePacket.setFadeOutTime(1);
             titlePacket.setStayTime(2);
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 0781a04b2..370604db9 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
@@ -28,7 +28,9 @@ package org.geysermc.geyser.network.netty;
 import com.github.steveice10.packetlib.BuiltinFlags;
 import com.github.steveice10.packetlib.codec.PacketCodecHelper;
 import com.github.steveice10.packetlib.packet.PacketProtocol;
-import com.github.steveice10.packetlib.tcp.*;
+import com.github.steveice10.packetlib.tcp.TcpPacketCodec;
+import com.github.steveice10.packetlib.tcp.TcpPacketSizer;
+import com.github.steveice10.packetlib.tcp.TcpSession;
 import io.netty.bootstrap.Bootstrap;
 import io.netty.buffer.ByteBufAllocator;
 import io.netty.channel.*;
diff --git a/core/src/main/java/org/geysermc/geyser/pack/ResourcePack.java b/core/src/main/java/org/geysermc/geyser/pack/ResourcePack.java
index c0913f31c..6df1a0c0e 100644
--- a/core/src/main/java/org/geysermc/geyser/pack/ResourcePack.java
+++ b/core/src/main/java/org/geysermc/geyser/pack/ResourcePack.java
@@ -25,21 +25,25 @@
 
 package org.geysermc.geyser.pack;
 
+import lombok.Getter;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.event.lifecycle.GeyserLoadResourcePacksEvent;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.util.FileUtils;
 
 import java.io.File;
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
-import lombok.Getter;
-
 /**
  * This represents a resource pack and all the data relevant to it
  */
@@ -66,16 +70,33 @@ public class ResourcePack {
      * Loop through the packs directory and locate valid resource pack files
      */
     public static void loadPacks() {
-        File directory = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("packs").toFile();
+        Path directory = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("packs");
 
-        if (!directory.exists()) {
-            directory.mkdir();
+        if (!Files.exists(directory)) {
+            try {
+                Files.createDirectory(directory);
+            } catch (IOException e) {
+                GeyserImpl.getInstance().getLogger().error("Could not create packs directory", e);
+            }
 
             // As we just created the directory it will be empty
             return;
         }
 
-        for (File file : directory.listFiles()) {
+        List<Path> resourcePacks;
+        try {
+            resourcePacks = Files.walk(directory).collect(Collectors.toList());
+        } catch (IOException e) {
+            GeyserImpl.getInstance().getLogger().error("Could not list packs directory", e);
+            return;
+        }
+
+        GeyserLoadResourcePacksEvent event = new GeyserLoadResourcePacksEvent(resourcePacks);
+        GeyserImpl.getInstance().eventBus().fire(event);
+
+        for (Path path : event.resourcePacks()) {
+            File file = path.toFile();
+
             if (file.getName().endsWith(".zip") || file.getName().endsWith(".mcpack")) {
                 ResourcePack pack = new ResourcePack();
 
diff --git a/core/src/main/java/org/geysermc/geyser/ping/GeyserLegacyPingPassthrough.java b/core/src/main/java/org/geysermc/geyser/ping/GeyserLegacyPingPassthrough.java
index 7db571be0..a69d9bc3e 100644
--- a/core/src/main/java/org/geysermc/geyser/ping/GeyserLegacyPingPassthrough.java
+++ b/core/src/main/java/org/geysermc/geyser/ping/GeyserLegacyPingPassthrough.java
@@ -32,7 +32,7 @@ import io.netty.handler.codec.haproxy.HAProxyCommand;
 import io.netty.handler.codec.haproxy.HAProxyProxiedProtocol;
 import io.netty.util.NetUtil;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
@@ -77,14 +77,14 @@ public class GeyserLegacyPingPassthrough implements IGeyserPingPassthrough, Runn
     @Override
     public void run() {
         try (Socket socket = new Socket()) {
-            String address = geyser.getConfig().getRemote().getAddress();
-            int port = geyser.getConfig().getRemote().getPort();
+            String address = geyser.getConfig().getRemote().address();
+            int port = geyser.getConfig().getRemote().port();
             socket.connect(new InetSocketAddress(address, port), 5000);
 
             ByteArrayOutputStream byteArrayStream = new ByteArrayOutputStream();
             try (DataOutputStream handshake = new DataOutputStream(byteArrayStream)) {
                 handshake.write(0x0);
-                VarInts.writeUnsignedInt(handshake, MinecraftProtocol.getJavaProtocolVersion());
+                VarInts.writeUnsignedInt(handshake, GameProtocol.getJavaProtocolVersion());
                 VarInts.writeUnsignedInt(handshake, address.length());
                 handshake.writeBytes(address);
                 handshake.writeShort(port);
diff --git a/core/src/main/java/org/geysermc/geyser/registry/PacketTranslatorRegistry.java b/core/src/main/java/org/geysermc/geyser/registry/PacketTranslatorRegistry.java
index 3f7d88031..bf412bfaf 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/PacketTranslatorRegistry.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/PacketTranslatorRegistry.java
@@ -31,10 +31,10 @@ import com.nukkitx.protocol.bedrock.BedrockPacket;
 import io.netty.channel.EventLoop;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.loader.RegistryLoaders;
-import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
 
 import java.util.Collections;
 import java.util.IdentityHashMap;
diff --git a/core/src/main/java/org/geysermc/geyser/registry/Registries.java b/core/src/main/java/org/geysermc/geyser/registry/Registries.java
index 8f2a9775a..4b361ba4f 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/Registries.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/Registries.java
@@ -47,6 +47,7 @@ import org.geysermc.geyser.registry.loader.*;
 import org.geysermc.geyser.registry.populator.ItemRegistryPopulator;
 import org.geysermc.geyser.registry.populator.PacketRegistryPopulator;
 import org.geysermc.geyser.registry.populator.RecipeRegistryPopulator;
+import org.geysermc.geyser.registry.provider.ProviderSupplier;
 import org.geysermc.geyser.registry.type.EnchantmentData;
 import org.geysermc.geyser.registry.type.ItemMappings;
 import org.geysermc.geyser.registry.type.ParticleMapping;
@@ -57,10 +58,7 @@ import org.geysermc.geyser.translator.level.event.LevelEventTranslator;
 import org.geysermc.geyser.translator.sound.SoundInteractionTranslator;
 import org.geysermc.geyser.translator.sound.SoundTranslator;
 
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
 
 /**
  * Holds all the common registries in Geyser.
@@ -138,6 +136,11 @@ public final class Registries {
      */
     public static final SimpleRegistry<Set<PotionMixData>> POTION_MIXES;
 
+    /**
+     * A registry holding all the
+     */
+    public static final SimpleMappedRegistry<Class<?>, ProviderSupplier> PROVIDERS = SimpleMappedRegistry.create(new IdentityHashMap<>(), ProviderRegistryLoader::new);
+
     /**
      * A versioned registry holding all the recipes, with the net ID being the key, and {@link GeyserRecipe} as the value.
      */
diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/CollisionRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/CollisionRegistryLoader.java
index b74573a4e..69ad16743 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/loader/CollisionRegistryLoader.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/loader/CollisionRegistryLoader.java
@@ -34,12 +34,12 @@ import it.unimi.dsi.fastutil.objects.ObjectArrayList;
 import lombok.AllArgsConstructor;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.level.physics.BoundingBox;
-import org.geysermc.geyser.translator.collision.CollisionRemapper;
-import org.geysermc.geyser.translator.collision.BlockCollision;
-import org.geysermc.geyser.translator.collision.OtherCollision;
-import org.geysermc.geyser.translator.collision.SolidCollision;
 import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.registry.type.BlockMapping;
+import org.geysermc.geyser.translator.collision.BlockCollision;
+import org.geysermc.geyser.translator.collision.CollisionRemapper;
+import org.geysermc.geyser.translator.collision.OtherCollision;
+import org.geysermc.geyser.translator.collision.SolidCollision;
 import org.geysermc.geyser.util.FileUtils;
 
 import java.io.InputStream;
diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/EnchantmentRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/EnchantmentRegistryLoader.java
index e566ff37c..8ad09bf88 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/loader/EnchantmentRegistryLoader.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/loader/EnchantmentRegistryLoader.java
@@ -30,7 +30,7 @@ import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
 import it.unimi.dsi.fastutil.ints.IntSet;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.inventory.item.Enchantment.JavaEnchantment;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.EnchantmentData;
 import org.geysermc.geyser.registry.type.ItemMapping;
@@ -77,7 +77,7 @@ public class EnchantmentRegistryLoader implements RegistryLoader<String, Map<Jav
             IntSet validItems = new IntOpenHashSet();
             for (JsonNode itemNode : node.get("valid_items")) {
                 String javaIdentifier = itemNode.textValue();
-                ItemMapping itemMapping = Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping(javaIdentifier);
+                ItemMapping itemMapping = Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping(javaIdentifier);
                 if (itemMapping != null) {
                     validItems.add(itemMapping.getJavaId());
                 } else {
diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/PotionMixRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/PotionMixRegistryLoader.java
index b0a9c72b2..8d40edac3 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/loader/PotionMixRegistryLoader.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/loader/PotionMixRegistryLoader.java
@@ -26,10 +26,10 @@
 package org.geysermc.geyser.registry.loader;
 
 import com.nukkitx.protocol.bedrock.data.inventory.PotionMixData;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.inventory.item.Potion;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
-import org.geysermc.geyser.inventory.item.Potion;
 
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -103,7 +103,7 @@ public class PotionMixRegistryLoader implements RegistryLoader<Object, Set<Potio
     }
 
     private static ItemMapping getNonNull(String javaIdentifier) {
-        ItemMapping itemMapping = Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping(javaIdentifier);
+        ItemMapping itemMapping = Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping(javaIdentifier);
         if (itemMapping == null)
             throw new NullPointerException("No item entry exists for java identifier: " + javaIdentifier);
 
diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java
new file mode 100644
index 000000000..99a9213fe
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java
@@ -0,0 +1,58 @@
+/*
+ * 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.registry.loader;
+
+import org.geysermc.geyser.api.command.Command;
+import org.geysermc.geyser.api.event.EventRegistrar;
+import org.geysermc.geyser.api.extension.Extension;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData;
+import org.geysermc.geyser.command.GeyserCommandManager;
+import org.geysermc.geyser.event.GeyserEventRegistrar;
+import org.geysermc.geyser.item.GeyserCustomItemData;
+import org.geysermc.geyser.item.GeyserCustomItemOptions;
+import org.geysermc.geyser.item.GeyserNonVanillaCustomItemData;
+import org.geysermc.geyser.registry.provider.ProviderSupplier;
+
+import java.util.Map;
+
+/**
+ * Registers the provider data from the provider.
+ */
+public class ProviderRegistryLoader implements RegistryLoader<Map<Class<?>, ProviderSupplier>, Map<Class<?>, ProviderSupplier>> {
+
+    @Override
+    public Map<Class<?>, ProviderSupplier> load(Map<Class<?>, ProviderSupplier> providers) {
+        providers.put(Command.Builder.class, args -> new GeyserCommandManager.CommandBuilder<>((Extension) args[0]));
+        providers.put(CustomItemData.Builder.class, args -> new GeyserCustomItemData.CustomItemDataBuilder());
+        providers.put(CustomItemOptions.Builder.class, args -> new GeyserCustomItemOptions.CustomItemOptionsBuilder());
+        providers.put(NonVanillaCustomItemData.Builder.class, args -> new GeyserNonVanillaCustomItemData.NonVanillaCustomItemDataBuilder());
+        providers.put(EventRegistrar.class, args -> new GeyserEventRegistrar(args[0]));
+
+        return providers;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/SoundTranslatorRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/SoundTranslatorRegistryLoader.java
index 359cd112e..558864b35 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/loader/SoundTranslatorRegistryLoader.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/loader/SoundTranslatorRegistryLoader.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser.registry.loader;
 
-import org.geysermc.geyser.translator.sound.SoundTranslator;
 import org.geysermc.geyser.translator.sound.SoundInteractionTranslator;
+import org.geysermc.geyser.translator.sound.SoundTranslator;
 
 import java.util.function.Function;
 
diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java
new file mode 100644
index 000000000..64543272e
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java
@@ -0,0 +1,360 @@
+/*
+ * 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.registry.populator;
+
+import com.nukkitx.nbt.NbtMap;
+import com.nukkitx.nbt.NbtMapBuilder;
+import com.nukkitx.nbt.NbtType;
+import com.nukkitx.protocol.bedrock.data.inventory.ComponentItemData;
+import com.nukkitx.protocol.bedrock.packet.StartGamePacket;
+import it.unimi.dsi.fastutil.objects.Object2IntMaps;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.CustomRenderOffsets;
+import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData;
+import org.geysermc.geyser.item.GeyserCustomMappingData;
+import org.geysermc.geyser.item.components.ToolBreakSpeedsUtils;
+import org.geysermc.geyser.item.components.WearableSlot;
+import org.geysermc.geyser.registry.type.GeyserMappingItem;
+import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.registry.type.NonVanillaItemRegistration;
+
+import javax.annotation.Nullable;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+public class CustomItemRegistryPopulator {
+    public static GeyserCustomMappingData registerCustomItem(String customItemName, GeyserMappingItem javaItem, CustomItemData customItemData, int bedrockId) {
+        StartGamePacket.ItemEntry startGamePacketItemEntry = new StartGamePacket.ItemEntry(customItemName, (short) bedrockId, true);
+
+        NbtMapBuilder builder = createComponentNbt(customItemData, javaItem, customItemName, bedrockId);
+        ComponentItemData componentItemData = new ComponentItemData(customItemName, builder.build());
+
+        return new GeyserCustomMappingData(componentItemData, startGamePacketItemEntry, customItemName, bedrockId);
+    }
+
+    static boolean initialCheck(String identifier, CustomItemData item, Map<String, GeyserMappingItem> mappings) {
+        if (!mappings.containsKey(identifier)) {
+            GeyserImpl.getInstance().getLogger().error("Could not find the Java item to add custom item properties to for " + item.name());
+            return false;
+        }
+        if (!item.customItemOptions().hasCustomItemOptions()) {
+            GeyserImpl.getInstance().getLogger().error("The custom item " + item.name() + " has no registration types");
+        }
+        return true;
+    }
+
+    public static NonVanillaItemRegistration registerCustomItem(NonVanillaCustomItemData customItemData, int customItemId) {
+        String customIdentifier = customItemData.identifier();
+
+        ItemMapping customItemMapping = ItemMapping.builder()
+                .javaIdentifier(customIdentifier)
+                .bedrockIdentifier(customIdentifier)
+                .javaId(customItemData.javaId())
+                .bedrockId(customItemId)
+                .bedrockData(0)
+                .bedrockBlockId(0)
+                .stackSize(customItemData.stackSize())
+                .toolType(customItemData.toolType())
+                .toolTier(customItemData.toolTier())
+                .translationString(customItemData.translationString())
+                .maxDamage(customItemData.maxDamage())
+                .repairMaterials(customItemData.repairMaterials())
+                .hasSuspiciousStewEffect(false)
+                .customItemOptions(Object2IntMaps.emptyMap())
+                .build();
+
+        NbtMapBuilder builder = createComponentNbt(customItemData, customItemData.identifier(), customItemId,
+                customItemData.creativeCategory(), customItemData.creativeGroup(), customItemData.isHat(), customItemData.isTool());
+        ComponentItemData componentItemData = new ComponentItemData(customIdentifier, builder.build());
+
+        return new NonVanillaItemRegistration(componentItemData, customItemMapping);
+    }
+
+    private static NbtMapBuilder createComponentNbt(CustomItemData customItemData, GeyserMappingItem mapping,
+                                                    String customItemName, int customItemId) {
+        NbtMapBuilder builder = NbtMap.builder();
+        builder.putString("name", customItemName)
+                .putInt("id", customItemId);
+
+        NbtMapBuilder itemProperties = NbtMap.builder();
+        NbtMapBuilder componentBuilder = NbtMap.builder();
+
+        setupBasicItemInfo(mapping.getMaxDamage(), mapping.getStackSize(), mapping.getToolType() != null, customItemData, itemProperties, componentBuilder);
+
+        boolean canDestroyInCreative = true;
+        if (mapping.getToolType() != null) { // This is not using the isTool boolean because it is not just a render type here.
+            canDestroyInCreative = computeToolProperties(mapping.getToolTier(), mapping.getToolType(), itemProperties, componentBuilder);
+        }
+        itemProperties.putBoolean("can_destroy_in_creative", canDestroyInCreative);
+
+        if (mapping.getArmorType() != null) {
+            computeArmorProperties(mapping.getArmorType(), mapping.getProtectionValue(), componentBuilder);
+        }
+
+        computeRenderOffsets(false, customItemData, componentBuilder);
+
+        componentBuilder.putCompound("item_properties", itemProperties.build());
+        builder.putCompound("components", componentBuilder.build());
+
+        return builder;
+    }
+
+    private static NbtMapBuilder createComponentNbt(NonVanillaCustomItemData customItemData, String customItemName,
+                                                    int customItemId, OptionalInt creativeCategory,
+                                                    String creativeGroup, boolean isHat, boolean isTool) {
+        NbtMapBuilder builder = NbtMap.builder();
+        builder.putString("name", customItemName)
+                .putInt("id", customItemId);
+
+        NbtMapBuilder itemProperties = NbtMap.builder();
+        NbtMapBuilder componentBuilder = NbtMap.builder();
+
+        setupBasicItemInfo(customItemData.maxDamage(), customItemData.stackSize(), isTool, customItemData, itemProperties, componentBuilder);
+
+        boolean canDestroyInCreative = true;
+        if (customItemData.toolType() != null) { // This is not using the isTool boolean because it is not just a render type here.
+            canDestroyInCreative = computeToolProperties(customItemData.toolTier(), customItemData.toolType(), itemProperties, componentBuilder);
+        }
+        itemProperties.putBoolean("can_destroy_in_creative", canDestroyInCreative);
+
+        String armorType = customItemData.armorType();
+        if (armorType != null) {
+            computeArmorProperties(armorType, customItemData.protectionValue(), componentBuilder);
+        }
+
+        computeRenderOffsets(isHat, customItemData, componentBuilder);
+
+        if (creativeGroup != null) {
+            itemProperties.putString("creative_group", creativeGroup);
+        }
+        if (creativeCategory.isPresent()) {
+            itemProperties.putInt("creative_category", creativeCategory.getAsInt());
+        }
+
+        componentBuilder.putCompound("item_properties", itemProperties.build());
+        builder.putCompound("components", componentBuilder.build());
+
+        return builder;
+    }
+
+    private static void setupBasicItemInfo(int maxDamage, int stackSize, boolean isTool, CustomItemData customItemData, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) {
+        itemProperties.putCompound("minecraft:icon", NbtMap.builder()
+                .putString("texture", customItemData.icon())
+                .build());
+        componentBuilder.putCompound("minecraft:display_name", NbtMap.builder().putString("value", customItemData.displayName()).build());
+
+        itemProperties.putBoolean("allow_off_hand", customItemData.allowOffhand());
+        itemProperties.putBoolean("hand_equipped", isTool);
+        itemProperties.putInt("max_stack_size", stackSize);
+        if (maxDamage > 0) {
+            componentBuilder.putCompound("minecraft:durability", NbtMap.builder()
+                    .putCompound("damage_chance", NbtMap.builder()
+                            .putInt("max", 1)
+                            .putInt("min", 1)
+                            .build())
+                    .putInt("max_durability", maxDamage)
+                    .build());
+            itemProperties.putBoolean("use_duration", true);
+        }
+    }
+
+    /**
+     * @return can destroy in creative
+     */
+    private static boolean computeToolProperties(String toolTier, String toolType, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) {
+        boolean canDestroyInCreative = true;
+        float miningSpeed = 1.0f;
+
+        if (toolType.equals("shears")) {
+            componentBuilder.putCompound("minecraft:digger", ToolBreakSpeedsUtils.getShearsDigger(15));
+        } else {
+            int toolSpeed = ToolBreakSpeedsUtils.toolTierToSpeed(toolTier);
+            switch (toolType) {
+                case "sword" -> {
+                    miningSpeed = 1.5f;
+                    canDestroyInCreative = false;
+                    componentBuilder.putCompound("minecraft:digger", ToolBreakSpeedsUtils.getSwordDigger(toolSpeed));
+                    componentBuilder.putCompound("minecraft:weapon", NbtMap.EMPTY);
+                }
+                case "pickaxe" -> {
+                    componentBuilder.putCompound("minecraft:digger", ToolBreakSpeedsUtils.getPickaxeDigger(toolSpeed, toolTier));
+                    setItemTag(componentBuilder, "pickaxe");
+                }
+                case "axe" -> {
+                    componentBuilder.putCompound("minecraft:digger", ToolBreakSpeedsUtils.getAxeDigger(toolSpeed));
+                    setItemTag(componentBuilder, "axe");
+                }
+                case "shovel" -> {
+                    componentBuilder.putCompound("minecraft:digger", ToolBreakSpeedsUtils.getShovelDigger(toolSpeed));
+                    setItemTag(componentBuilder, "shovel");
+                }
+                case "hoe" -> {
+                    componentBuilder.putCompound("minecraft:digger", ToolBreakSpeedsUtils.getHoeDigger(toolSpeed));
+                    setItemTag(componentBuilder, "hoe");
+                }
+            }
+        }
+
+        itemProperties.putBoolean("hand_equipped", true);
+        itemProperties.putFloat("mining_speed", miningSpeed);
+
+        return canDestroyInCreative;
+    }
+
+    private static void computeArmorProperties(String armorType, int protectionValue, NbtMapBuilder componentBuilder) {
+        switch (armorType) {
+            case "boots" -> {
+                componentBuilder.putString("minecraft:render_offsets", "boots");
+                componentBuilder.putCompound("minecraft:wearable", WearableSlot.FEET.getSlotNbt());
+                componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build());
+            }
+            case "chestplate" -> {
+                componentBuilder.putString("minecraft:render_offsets", "chestplates");
+                componentBuilder.putCompound("minecraft:wearable", WearableSlot.CHEST.getSlotNbt());
+                componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build());
+            }
+            case "leggings" -> {
+                componentBuilder.putString("minecraft:render_offsets", "leggings");
+                componentBuilder.putCompound("minecraft:wearable", WearableSlot.LEGS.getSlotNbt());
+                componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build());
+            }
+            case "helmet" -> {
+                componentBuilder.putString("minecraft:render_offsets", "helmets");
+                componentBuilder.putCompound("minecraft:wearable", WearableSlot.HEAD.getSlotNbt());
+                componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build());
+            }
+        }
+    }
+
+    private static void computeRenderOffsets(boolean isHat, CustomItemData customItemData, NbtMapBuilder componentBuilder) {
+        if (isHat) {
+            componentBuilder.remove("minecraft:render_offsets");
+            componentBuilder.putString("minecraft:render_offsets", "helmets");
+
+            componentBuilder.remove("minecraft:wearable");
+            componentBuilder.putCompound("minecraft:wearable", WearableSlot.HEAD.getSlotNbt());
+        }
+
+        CustomRenderOffsets renderOffsets = customItemData.renderOffsets();
+        if (renderOffsets != null) {
+            componentBuilder.remove("minecraft:render_offsets");
+            componentBuilder.putCompound("minecraft:render_offsets", toNbtMap(renderOffsets));
+        } else if (customItemData.textureSize() != 16 && !componentBuilder.containsKey("minecraft:render_offsets")) {
+            float scale1 = (float) (0.075 / (customItemData.textureSize() / 16f));
+            float scale2 = (float) (0.125 / (customItemData.textureSize() / 16f));
+            float scale3 = (float) (0.075 / (customItemData.textureSize() / 16f * 2.4f));
+
+            componentBuilder.putCompound("minecraft:render_offsets",
+                    NbtMap.builder().putCompound("main_hand", NbtMap.builder()
+                                    .putCompound("first_person", xyzToScaleList(scale3, scale3, scale3))
+                                    .putCompound("third_person", xyzToScaleList(scale1, scale2, scale1)).build())
+                            .putCompound("off_hand", NbtMap.builder()
+                                    .putCompound("first_person", xyzToScaleList(scale1, scale2, scale1))
+                                    .putCompound("third_person", xyzToScaleList(scale1, scale2, scale1)).build()).build());
+        }
+    }
+
+    private static NbtMap toNbtMap(CustomRenderOffsets renderOffsets) {
+        NbtMapBuilder builder = NbtMap.builder();
+
+        CustomRenderOffsets.Hand mainHand = renderOffsets.mainHand();
+        if (mainHand != null) {
+            NbtMap nbt = toNbtMap(mainHand);
+            if (nbt != null) {
+                builder.putCompound("main_hand", nbt);
+            }
+        }
+        CustomRenderOffsets.Hand offhand = renderOffsets.offhand();
+        if (offhand != null) {
+            NbtMap nbt = toNbtMap(offhand);
+            if (nbt != null) {
+                builder.putCompound("off_hand", nbt);
+            }
+        }
+
+        return builder.build();
+    }
+
+    private static NbtMap toNbtMap(CustomRenderOffsets.Hand hand) {
+        NbtMap firstPerson = toNbtMap(hand.firstPerson());
+        NbtMap thirdPerson = toNbtMap(hand.thirdPerson());
+
+        if (firstPerson == null && thirdPerson == null) {
+            return null;
+        }
+
+        NbtMapBuilder builder = NbtMap.builder();
+        if (firstPerson != null) {
+            builder.putCompound("first_person", firstPerson);
+        }
+        if (thirdPerson != null) {
+            builder.putCompound("third_person", thirdPerson);
+        }
+
+        return builder.build();
+    }
+
+    private static NbtMap toNbtMap(@Nullable CustomRenderOffsets.Offset offset) {
+        if (offset == null) {
+            return null;
+        }
+
+        CustomRenderOffsets.OffsetXYZ position = offset.position();
+        CustomRenderOffsets.OffsetXYZ rotation = offset.rotation();
+        CustomRenderOffsets.OffsetXYZ scale = offset.scale();
+
+        if (position == null && rotation == null && scale == null) {
+            return null;
+        }
+
+        NbtMapBuilder builder = NbtMap.builder();
+        if (position != null) {
+            builder.putList("position", NbtType.FLOAT, toList(position));
+        }
+        if (rotation != null) {
+            builder.putList("rotation", NbtType.FLOAT, toList(rotation));
+        }
+        if (scale != null) {
+            builder.putList("scale", NbtType.FLOAT, toList(scale));
+        }
+
+        return builder.build();
+    }
+
+    private static List<Float> toList(CustomRenderOffsets.OffsetXYZ xyz) {
+        return List.of(xyz.x(), xyz.y(), xyz.z());
+    }
+
+    private static void setItemTag(NbtMapBuilder builder, String tag) {
+        builder.putList("item_tags", NbtType.STRING, List.of("minecraft:is_" + tag));
+    }
+
+    private static NbtMap xyzToScaleList(float x, float y, float z) {
+        return NbtMap.builder().putList("scale", NbtType.FLOAT, List.of(x, y, z)).build();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java
index ad1020e9b..60a16245c 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java
@@ -27,6 +27,8 @@ package org.geysermc.geyser.registry.populator;
 
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.nbt.NbtType;
@@ -35,6 +37,7 @@ import com.nukkitx.protocol.bedrock.data.SoundEvent;
 import com.nukkitx.protocol.bedrock.data.inventory.ComponentItemData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import com.nukkitx.protocol.bedrock.packet.StartGamePacket;
+import it.unimi.dsi.fastutil.ints.*;
 import com.nukkitx.protocol.bedrock.v527.Bedrock_v527;
 import com.nukkitx.protocol.bedrock.v534.Bedrock_v534;
 import com.nukkitx.protocol.bedrock.v544.Bedrock_v544;
@@ -42,9 +45,16 @@ import it.unimi.dsi.fastutil.ints.Int2IntMap;
 import it.unimi.dsi.fastutil.ints.IntArrayList;
 import it.unimi.dsi.fastutil.ints.IntList;
 import it.unimi.dsi.fastutil.objects.*;
+import org.checkerframework.checker.nullness.qual.NonNull;
 import org.geysermc.geyser.GeyserBootstrap;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.item.custom.CustomItemData;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData;
+import org.geysermc.geyser.event.type.GeyserDefineCustomItemsEventImpl;
 import org.geysermc.geyser.inventory.item.StoredItemMappings;
+import org.geysermc.geyser.item.GeyserCustomMappingData;
+import org.geysermc.geyser.item.mappings.MappingsConfigReader;
 import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.*;
@@ -83,6 +93,58 @@ public class ItemRegistryPopulator {
             throw new AssertionError("Unable to load Java runtime item IDs", e);
         }
 
+        boolean customItemsAllowed = GeyserImpl.getInstance().getConfig().isAddNonBedrockItems();
+
+        Multimap<String, CustomItemData> customItems = MultimapBuilder.hashKeys().hashSetValues().build();
+        List<NonVanillaCustomItemData> nonVanillaCustomItems;
+
+        MappingsConfigReader mappingsConfigReader = new MappingsConfigReader();
+        if (customItemsAllowed) {
+            // Load custom items from mappings files
+            mappingsConfigReader.loadMappingsFromJson((key, item) -> {
+                if (CustomItemRegistryPopulator.initialCheck(key, item, items)) {
+                    customItems.get(key).add(item);
+                }
+            });
+
+            nonVanillaCustomItems = new ObjectArrayList<>();
+            GeyserImpl.getInstance().eventBus().fire(new GeyserDefineCustomItemsEventImpl(customItems, nonVanillaCustomItems) {
+                @Override
+                public boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData) {
+                    if (CustomItemRegistryPopulator.initialCheck(identifier, customItemData, items)) {
+                        customItems.get(identifier).add(customItemData);
+                        return true;
+                    }
+                    return false;
+                }
+
+                @Override
+                public boolean register(@NonNull NonVanillaCustomItemData customItemData) {
+                    if (customItemData.identifier().startsWith("minecraft:")) {
+                        GeyserImpl.getInstance().getLogger().error("The custom item " + customItemData.identifier() +
+                                " is attempting to masquerade as a vanilla Minecraft item!");
+                        return false;
+                    }
+
+                    if (customItemData.javaId() < items.size()) {
+                        // Attempting to overwrite an item that already exists in the protocol
+                        GeyserImpl.getInstance().getLogger().error("The custom item " + customItemData.identifier() +
+                                " is attempting to overwrite a vanilla Minecraft item!");
+                        return false;
+                    }
+                    nonVanillaCustomItems.add(customItemData);
+                    return true;
+                }
+            });
+        } else {
+            nonVanillaCustomItems = Collections.emptyList();
+        }
+
+        int customItemCount = customItems.size() + nonVanillaCustomItems.size();
+        if (customItemCount > 0) {
+            GeyserImpl.getInstance().getLogger().info("Registered " + customItemCount + " custom items");
+        }
+
         // We can reduce some operations as Java information is the same across all palette versions
         boolean firstMappingsPass = true;
         Int2IntMap dyeColors = new FixedInt2IntMap();
@@ -104,11 +166,20 @@ public class ItemRegistryPopulator {
                 throw new AssertionError("Unable to load Bedrock runtime item IDs", e);
             }
 
+            // Used for custom items
+            int nextFreeBedrockId = 0;
+            List<ComponentItemData> componentItemData = new ObjectArrayList<>();
+
             Map<String, StartGamePacket.ItemEntry> entries = new Object2ObjectOpenHashMap<>();
 
             for (PaletteItem entry : itemEntries) {
-                entries.put(entry.getName(), new StartGamePacket.ItemEntry(entry.getName(), (short) entry.getId()));
-                bedrockIdentifierToId.put(entry.getName(), entry.getId());
+                int id = entry.getId();
+                if (id >= nextFreeBedrockId) {
+                    nextFreeBedrockId = id + 1;
+                }
+
+                entries.put(entry.getName(), new StartGamePacket.ItemEntry(entry.getName(), (short) id));
+                bedrockIdentifierToId.put(entry.getName(), id);
             }
 
             Object2IntMap<String> bedrockBlockIdOverrides = new Object2IntOpenHashMap<>();
@@ -211,17 +282,19 @@ public class ItemRegistryPopulator {
 
             int itemIndex = 0;
             int javaFurnaceMinecartId = 0;
-            boolean usingFurnaceMinecart = GeyserImpl.getInstance().getConfig().isAddNonBedrockItems();
 
             Set<String> javaOnlyItems = new ObjectOpenHashSet<>();
             Collections.addAll(javaOnlyItems, "minecraft:spectral_arrow", "minecraft:debug_stick",
                     "minecraft:knowledge_book", "minecraft:tipped_arrow", "minecraft:bundle");
-            if (!usingFurnaceMinecart) {
+            if (!customItemsAllowed) {
                 javaOnlyItems.add("minecraft:furnace_minecart");
             }
             // Java-only items for this version
             javaOnlyItems.addAll(palette.getValue().additionalTranslatedItems().keySet());
 
+            Int2ObjectMap<String> customIdMappings = new Int2ObjectOpenHashMap<>();
+            Set<String> registeredItemNames = new ObjectOpenHashSet<>(); // This is used to check for duplicate item names
+
             for (Map.Entry<String, GeyserMappingItem> entry : items.entrySet()) {
                 String javaIdentifier = entry.getKey().intern();
                 GeyserMappingItem mappingItem;
@@ -233,7 +306,7 @@ public class ItemRegistryPopulator {
                     mappingItem = entry.getValue();
                 }
 
-                if (usingFurnaceMinecart && javaIdentifier.equals("minecraft:furnace_minecart")) {
+                if (customItemsAllowed && javaIdentifier.equals("minecraft:furnace_minecart")) {
                     javaFurnaceMinecartId = itemIndex;
                     itemIndex++;
                     // Will be added later
@@ -387,12 +460,46 @@ public class ItemRegistryPopulator {
                                 .toolTier("");
                     }
                 }
+
                 if (javaOnlyItems.contains(javaIdentifier)) {
                     // These items don't exist on Bedrock, so set up a variable that indicates they should have custom names
                     mappingBuilder = mappingBuilder.translationString((bedrockBlockId != -1 ? "block." : "item.") + entry.getKey().replace(":", "."));
                     GeyserImpl.getInstance().getLogger().debug("Adding " + entry.getKey() + " as an item that needs to be translated.");
                 }
 
+                // Add the custom item properties, if applicable
+                Object2IntMap<CustomItemOptions> customItemOptions;
+                Collection<CustomItemData> customItemsToLoad = customItems.get(javaIdentifier);
+                if (customItemsAllowed && !customItemsToLoad.isEmpty()) {
+                    customItemOptions = new Object2IntOpenHashMap<>(customItemsToLoad.size());
+
+                    for (CustomItemData customItem : customItemsToLoad) {
+                        int customProtocolId = nextFreeBedrockId++;
+
+                        String customItemName = "geyser_custom:" + customItem.name();
+                        if (!registeredItemNames.add(customItemName)) {
+                            if (firstMappingsPass) {
+                                GeyserImpl.getInstance().getLogger().error("Custom item name '" + customItem.name() + "' already exists and was registered again! Skipping...");
+                            }
+                            continue;
+                        }
+
+                        GeyserCustomMappingData customMapping = CustomItemRegistryPopulator.registerCustomItem(
+                                customItemName, mappingItem, customItem, customProtocolId
+                        );
+                        // StartGamePacket entry - needed for Bedrock to recognize the item through the protocol
+                        entries.put(customMapping.stringId(), customMapping.startGamePacketItemEntry());
+                        // ComponentItemData - used to register some custom properties
+                        componentItemData.add(customMapping.componentItemData());
+                        customItemOptions.put(customItem.customItemOptions(), customProtocolId);
+
+                        customIdMappings.put(customMapping.integerId(), customMapping.stringId());
+                    }
+                } else {
+                    customItemOptions = Object2IntMaps.emptyMap();
+                }
+                mappingBuilder.customItemOptions(customItemOptions);
+
                 ItemMapping mapping = mappingBuilder.build();
 
                 if (javaIdentifier.contains("boat")) {
@@ -443,12 +550,12 @@ public class ItemRegistryPopulator {
                     .bedrockData(0)
                     .bedrockBlockId(-1)
                     .stackSize(1)
+                    .customItemOptions(Object2IntMaps.emptyMap())
                     .build();
 
-            ComponentItemData furnaceMinecartData = null;
-            if (usingFurnaceMinecart) {
+            if (customItemsAllowed) {
                 // Add the furnace minecart as a custom item
-                int furnaceMinecartId = mappings.size() + 1;
+                int furnaceMinecartId = nextFreeBedrockId++;
 
                 entries.put("geysermc:furnace_minecart", new StartGamePacket.ItemEntry("geysermc:furnace_minecart", (short) furnaceMinecartId, true));
 
@@ -463,7 +570,7 @@ public class ItemRegistryPopulator {
                         .build());
 
                 creativeItems.add(ItemData.builder()
-                        .netId(netId)
+                        .netId(netId++)
                         .id(furnaceMinecartId)
                         .count(1).build());
 
@@ -499,7 +606,36 @@ public class ItemRegistryPopulator {
 
                 componentBuilder.putCompound("item_properties", itemProperties.build());
                 builder.putCompound("components", componentBuilder.build());
-                furnaceMinecartData = new ComponentItemData("geysermc:furnace_minecart", builder.build());
+                componentItemData.add(new ComponentItemData("geysermc:furnace_minecart", builder.build()));
+
+                // Register any completely custom items given to us
+                IntSet registeredJavaIds = new IntOpenHashSet(); // Used to check for duplicate item java ids
+                for (NonVanillaCustomItemData customItem : nonVanillaCustomItems) {
+                    if (!registeredJavaIds.add(customItem.javaId())) {
+                        if (firstMappingsPass) {
+                            GeyserImpl.getInstance().getLogger().error("Custom item java id " + customItem.javaId() + " already exists and was registered again! Skipping...");
+                        }
+                        continue;
+                    }
+
+                    int customItemId = nextFreeBedrockId++;
+                    NonVanillaItemRegistration registration = CustomItemRegistryPopulator.registerCustomItem(customItem, customItemId);
+
+                    componentItemData.add(registration.componentItemData());
+                    ItemMapping mapping = registration.mapping();
+                    while (mapping.getJavaId() >= mappings.size()) {
+                        // Fill with empty to get to the correct size
+                        mappings.add(ItemMapping.AIR);
+                    }
+                    mappings.set(mapping.getJavaId(), mapping);
+
+                    if (customItem.creativeGroup() != null || customItem.creativeCategory().isPresent()) {
+                        creativeItems.add(ItemData.builder()
+                                .id(customItemId)
+                                .netId(netId++)
+                                .count(1).build());
+                    }
+                }
             }
 
             ItemMappings itemMappings = ItemMappings.builder()
@@ -513,8 +649,9 @@ public class ItemRegistryPopulator {
                     .boatIds(boats)
                     .spawnEggIds(spawnEggs)
                     .carpets(carpets)
-                    .furnaceMinecartData(furnaceMinecartData)
+                    .componentItemData(componentItemData)
                     .lodestoneCompass(lodestoneEntry)
+                    .customIdMappings(customIdMappings)
                     .build();
 
             Registries.ITEMS.register(palette.getValue().protocolVersion(), itemMappings);
diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/RecipeRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/RecipeRegistryPopulator.java
index f0a215f2a..920ada5fb 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/populator/RecipeRegistryPopulator.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/populator/RecipeRegistryPopulator.java
@@ -33,6 +33,7 @@ import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtUtils;
 import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.data.inventory.descriptor.ItemDescriptorWithCount;
 import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.objects.ObjectArrayList;
@@ -171,7 +172,7 @@ public class RecipeRegistryPopulator {
             /* Convert end */
 
             return CraftingData.fromShaped(uuid.toString(), shape.get(0).length(), shape.size(),
-                    inputs, Collections.singletonList(output), uuid, "crafting_table", 0, netId);
+                    inputs.stream().map(ItemDescriptorWithCount::fromItem).toList(), Collections.singletonList(output), uuid, "crafting_table", 0, netId);
         }
         List<ItemData> inputs = new ObjectArrayList<>();
         for (JsonNode entry : node.get("inputs")) {
@@ -191,10 +192,10 @@ public class RecipeRegistryPopulator {
         if (type == 5) {
             // Shulker box
             return CraftingData.fromShulkerBox(uuid.toString(),
-                    inputs, Collections.singletonList(output), uuid, "crafting_table", 0, netId);
+                    inputs.stream().map(ItemDescriptorWithCount::fromItem).toList(), Collections.singletonList(output), uuid, "crafting_table", 0, netId);
         }
         return CraftingData.fromShapeless(uuid.toString(),
-                inputs, Collections.singletonList(output), uuid, "crafting_table", 0, netId);
+                inputs.stream().map(ItemDescriptorWithCount::fromItem).toList(), Collections.singletonList(output), uuid, "crafting_table", 0, netId);
     }
 
     private static ItemData getBedrockItemFromIdentifierJson(ItemMapping mapping, JsonNode itemNode) {
diff --git a/core/src/main/java/org/geysermc/geyser/registry/provider/ProviderSupplier.java b/core/src/main/java/org/geysermc/geyser/registry/provider/ProviderSupplier.java
new file mode 100644
index 000000000..6cb220ce4
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/registry/provider/ProviderSupplier.java
@@ -0,0 +1,31 @@
+/*
+ * 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.registry.provider;
+
+public interface ProviderSupplier {
+
+    Object create(Object... args);
+}
diff --git a/core/src/main/java/org/geysermc/geyser/registry/type/BlockMapping.java b/core/src/main/java/org/geysermc/geyser/registry/type/BlockMapping.java
index 3fadcf5e5..cd91f64d1 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/type/BlockMapping.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/type/BlockMapping.java
@@ -27,8 +27,8 @@ package org.geysermc.geyser.registry.type;
 
 import lombok.Builder;
 import lombok.Value;
-import org.geysermc.geyser.util.BlockUtils;
 import org.geysermc.geyser.level.physics.PistonBehavior;
+import org.geysermc.geyser.util.BlockUtils;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
diff --git a/core/src/main/java/org/geysermc/geyser/registry/type/GeyserMappingItem.java b/core/src/main/java/org/geysermc/geyser/registry/type/GeyserMappingItem.java
index 9d06fd3a9..6c65f1c34 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/type/GeyserMappingItem.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/type/GeyserMappingItem.java
@@ -42,6 +42,8 @@ public class GeyserMappingItem {
     @JsonProperty("stack_size") int stackSize = 64;
     @JsonProperty("tool_type") String toolType;
     @JsonProperty("tool_tier") String toolTier;
+    @JsonProperty("armor_type") String armorType;
+    @JsonProperty("protection_value") int protectionValue;
     @JsonProperty("max_damage") int maxDamage = 0;
     @JsonProperty("repair_materials") List<String> repairMaterials;
     @JsonProperty("has_suspicious_stew_effect") boolean hasSuspiciousStewEffect = false;
diff --git a/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java b/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java
index 28d41ba46..12ba7d208 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java
@@ -25,10 +25,13 @@
 
 package org.geysermc.geyser.registry.type;
 
+import it.unimi.dsi.fastutil.objects.Object2IntMap;
+import it.unimi.dsi.fastutil.objects.Object2IntMaps;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Value;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.BlockRegistries;
 
 import java.util.Set;
@@ -38,8 +41,8 @@ import java.util.Set;
 @EqualsAndHashCode
 public class ItemMapping {
     public static final ItemMapping AIR = new ItemMapping("minecraft:air", "minecraft:air", 0, 0, 0,
-            BlockRegistries.BLOCKS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getBedrockAirId(),
-            64, null, null, null, 0, null, false);
+            BlockRegistries.BLOCKS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getBedrockAirId(),
+            64, null, null, null, Object2IntMaps.emptyMap(), 0, null, false);
 
     String javaIdentifier;
     String bedrockIdentifier;
@@ -59,6 +62,8 @@ public class ItemMapping {
 
     String translationString;
 
+    Object2IntMap<CustomItemOptions> customItemOptions;
+
     int maxDamage;
 
     Set<String> repairMaterials;
@@ -91,4 +96,4 @@ public class ItemMapping {
     public boolean isTool() {
         return this.toolType != null;
     }
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/org/geysermc/geyser/registry/type/ItemMappings.java b/core/src/main/java/org/geysermc/geyser/registry/type/ItemMappings.java
index ef1a8bc77..ce7ac0b07 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/type/ItemMappings.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/type/ItemMappings.java
@@ -29,6 +29,7 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.nukkitx.protocol.bedrock.data.inventory.ComponentItemData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import com.nukkitx.protocol.bedrock.packet.StartGamePacket;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 import it.unimi.dsi.fastutil.ints.IntList;
 import lombok.Builder;
 import lombok.Value;
@@ -36,7 +37,6 @@ import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.inventory.item.StoredItemMappings;
 
 import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -67,7 +67,8 @@ public class ItemMappings {
     IntList spawnEggIds;
     List<ItemData> carpets;
 
-    @Nullable ComponentItemData furnaceMinecartData;
+    List<ComponentItemData> componentItemData;
+    Int2ObjectMap<String> customIdMappings;
 
     /**
      * Gets an {@link ItemMapping} from the given {@link ItemStack}.
diff --git a/core/src/main/java/org/geysermc/geyser/registry/type/NonVanillaItemRegistration.java b/core/src/main/java/org/geysermc/geyser/registry/type/NonVanillaItemRegistration.java
new file mode 100644
index 000000000..e2063f41a
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/registry/type/NonVanillaItemRegistration.java
@@ -0,0 +1,34 @@
+/*
+ * 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.registry.type;
+
+import com.nukkitx.protocol.bedrock.data.inventory.ComponentItemData;
+
+/**
+ * The return data of a successful registration of a custom item.
+ */
+public record NonVanillaItemRegistration(ComponentItemData componentItemData, ItemMapping mapping) {
+}
\ No newline at end of file
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 45ae7eff2..fed3054b4 100644
--- a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java
+++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java
@@ -118,7 +118,7 @@ public final class ScoreboardUpdater extends Thread {
                                             FIRST_SCORE_PACKETS_PER_SECOND_THRESHOLD;
 
                                     geyser.getLogger().info(
-                                            GeyserLocale.getLocaleStringLog("geyser.scoreboard.updater.threshold_reached.log", session.name(), threshold, pps) +
+                                            GeyserLocale.getLocaleStringLog("geyser.scoreboard.updater.threshold_reached.log", session.bedrockUsername(), threshold, pps) +
                                                     GeyserLocale.getLocaleStringLog("geyser.scoreboard.updater.threshold_reached", (millisBetweenUpdates / 1000.0))
                                     );
 
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 4e00294a8..2fd1edd44 100644
--- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
+++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
@@ -88,8 +88,12 @@ import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.NonNull;
 import lombok.Setter;
+import lombok.experimental.Accessors;
 import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
 import org.checkerframework.common.value.qual.IntRange;
+import org.geysermc.api.util.BedrockPlatform;
+import org.geysermc.api.util.InputMode;
+import org.geysermc.api.util.UiProfile;
 import org.geysermc.common.PlatformType;
 import org.geysermc.cumulus.form.Form;
 import org.geysermc.cumulus.form.util.FormBuilder;
@@ -98,7 +102,9 @@ import org.geysermc.floodgate.util.BedrockData;
 import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.api.connection.GeyserConnection;
-import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.api.network.AuthType;
+import org.geysermc.geyser.api.network.RemoteServer;
+import org.geysermc.geyser.command.GeyserCommandSource;
 import org.geysermc.geyser.configuration.EmoteOffhandWorkaroundOption;
 import org.geysermc.geyser.entity.EntityDefinitions;
 import org.geysermc.geyser.entity.attribute.GeyserAttributeType;
@@ -113,13 +119,13 @@ import org.geysermc.geyser.inventory.recipe.GeyserStonecutterData;
 import org.geysermc.geyser.level.JavaDimension;
 import org.geysermc.geyser.level.WorldManager;
 import org.geysermc.geyser.level.physics.CollisionManager;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.network.netty.LocalSession;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.BlockMappings;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
 import org.geysermc.geyser.session.auth.AuthData;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.session.auth.BedrockClientData;
 import org.geysermc.geyser.session.cache.*;
 import org.geysermc.geyser.skin.FloodgateSkinUploader;
@@ -133,7 +139,6 @@ import org.geysermc.geyser.util.DimensionUtils;
 import org.geysermc.geyser.util.LoginEncryptionUtils;
 import org.geysermc.geyser.util.MathUtils;
 
-import javax.annotation.Nonnull;
 import java.net.ConnectException;
 import java.net.InetSocketAddress;
 import java.nio.charset.StandardCharsets;
@@ -145,15 +150,15 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
 @Getter
-public class GeyserSession implements GeyserConnection, CommandSender {
+public class GeyserSession implements GeyserConnection, GeyserCommandSource {
 
-    private final @Nonnull GeyserImpl geyser;
-    private final @Nonnull UpstreamSession upstream;
+    private final @NonNull GeyserImpl geyser;
+    private final @NonNull UpstreamSession upstream;
     /**
      * The loop where all packets and ticking is processed to prevent concurrency issues.
      * If this is manually called, ensure that any exceptions are properly handled.
      */
-    private final @Nonnull EventLoop eventLoop;
+    private final @NonNull EventLoop eventLoop;
     private TcpSession downstream;
     @Setter
     private AuthData authData;
@@ -165,14 +170,9 @@ public class GeyserSession implements GeyserConnection, CommandSender {
     @Setter
     private JsonNode certChainData;
 
-    /* Setter for GeyserConnect */
+    @Accessors(fluent = true)
     @Setter
-    private String remoteAddress;
-    @Setter
-    private int remotePort;
-    @Setter
-    private AuthType remoteAuthType;
-    /* Setter for GeyserConnect */
+    private RemoteServer remoteServer;
 
     @Deprecated
     @Setter
@@ -596,9 +596,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
             disconnect(message);
         });
 
-        this.remoteAddress = geyser.getConfig().getRemote().getAddress();
-        this.remotePort = geyser.getConfig().getRemote().getPort();
-        this.remoteAuthType = geyser.getConfig().getRemote().getAuthType();
+        this.remoteServer = geyser.defaultRemoteServer();
     }
 
     /**
@@ -611,9 +609,9 @@ public class GeyserSession implements GeyserConnection, CommandSender {
         // Set the hardcoded shield ID to the ID we just defined in StartGamePacket
         upstream.getSession().getHardcodedBlockingId().set(this.itemMappings.getStoredItems().shield().getBedrockId());
 
-        if (this.itemMappings.getFurnaceMinecartData() != null) {
+        if (GeyserImpl.getInstance().getConfig().isAddNonBedrockItems()) {
             ItemComponentPacket componentPacket = new ItemComponentPacket();
-            componentPacket.getItems().add(this.itemMappings.getFurnaceMinecartData());
+            componentPacket.getItems().addAll(itemMappings.getComponentItemData());
             upstream.sendPacket(componentPacket);
         }
 
@@ -695,7 +693,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
                     // However, this doesn't affect the final username as Floodgate is still in charge of that.
                     // So if you have (for example) replace spaces enabled on Floodgate the spaces will re-appear.
                     String validUsername = username;
-                    if (remoteAuthType == AuthType.FLOODGATE) {
+                    if (this.remoteServer.authType() == AuthType.FLOODGATE) {
                         validUsername = username.replace(' ', '_');
                     }
 
@@ -738,7 +736,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
             try {
                 service.login();
             } catch (RequestException e) {
-                geyser.getLogger().error("Error while attempting to use refresh token for " + name() + "!", e);
+                geyser.getLogger().error("Error while attempting to use refresh token for " + bedrockUsername() + "!", e);
                 return Boolean.FALSE;
             }
 
@@ -750,7 +748,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
             }
 
             protocol = new MinecraftProtocol(profile, service.getAccessToken());
-            geyser.saveRefreshToken(name(), service.getRefreshToken());
+            geyser.saveRefreshToken(bedrockUsername(), service.getRefreshToken());
             return Boolean.TRUE;
         }).whenComplete((successful, ex) -> {
             if (this.closed) {
@@ -841,7 +839,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
                 connectDownstream();
 
                 // Save our refresh token for later use
-                geyser.saveRefreshToken(name(), service.getRefreshToken());
+                geyser.saveRefreshToken(bedrockUsername(), service.getRefreshToken());
                 return true;
             }
         }
@@ -852,18 +850,18 @@ public class GeyserSession implements GeyserConnection, CommandSender {
      * After getting whatever credentials needed, we attempt to join the Java server.
      */
     private void connectDownstream() {
-        boolean floodgate = this.remoteAuthType == AuthType.FLOODGATE;
+        boolean floodgate = this.remoteServer.authType() == AuthType.FLOODGATE;
 
         // Start ticking
         tickThread = eventLoop.scheduleAtFixedRate(this::tick, 50, 50, TimeUnit.MILLISECONDS);
 
         if (geyser.getBootstrap().getSocketAddress() != null) {
             // We're going to connect through the JVM and not through TCP
-            downstream = new LocalSession(this.remoteAddress, this.remotePort,
+            downstream = new LocalSession(this.remoteServer.address(), this.remoteServer.port(),
                     geyser.getBootstrap().getSocketAddress(), upstream.getAddress().getAddress().getHostAddress(),
                     this.protocol, this.protocol.createHelper());
         } else {
-            downstream = new TcpClientSession(this.remoteAddress, this.remotePort, this.protocol);
+            downstream = new TcpClientSession(this.remoteServer.address(), this.remoteServer.port(), this.protocol);
             disableSrvResolving();
         }
 
@@ -943,13 +941,13 @@ public class GeyserSession implements GeyserConnection, CommandSender {
                 } else {
                     // Connected to an IP address
                     geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.remote.connect",
-                            authData.name(), protocol.getProfile().getName(), remoteAddress));
+                            authData.name(), protocol.getProfile().getName(), remoteServer.address()));
                 }
 
                 UUID uuid = protocol.getProfile().getId();
                 if (uuid == null) {
                     // Set what our UUID *probably* is going to be
-                    if (remoteAuthType == AuthType.FLOODGATE) {
+                    if (remoteServer.authType() == AuthType.FLOODGATE) {
                         uuid = new UUID(0, Long.parseLong(authData.xuid()));
                     } else {
                         uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + protocol.getProfile().getName()).getBytes(StandardCharsets.UTF_8));
@@ -979,9 +977,9 @@ public class GeyserSession implements GeyserConnection, CommandSender {
                 String disconnectMessage;
                 Throwable cause = event.getCause();
                 if (cause instanceof UnexpectedEncryptionException) {
-                    if (remoteAuthType != AuthType.FLOODGATE) {
+                    if (remoteServer.authType() != AuthType.FLOODGATE) {
                         // Server expects online mode
-                        disconnectMessage = GeyserLocale.getPlayerLocaleString("geyser.network.remote.authentication_type_mismatch", getLocale());
+                        disconnectMessage = GeyserLocale.getPlayerLocaleString("geyser.network.remote.authentication_type_mismatch", locale());
                         // Explain that they may be looking for Floodgate.
                         geyser.getLogger().warning(GeyserLocale.getLocaleStringLog(
                                 geyser.getPlatformType() == PlatformType.STANDALONE ?
@@ -991,14 +989,14 @@ public class GeyserSession implements GeyserConnection, CommandSender {
                         ));
                     } else {
                         // Likely that Floodgate is not configured correctly.
-                        disconnectMessage = GeyserLocale.getPlayerLocaleString("geyser.network.remote.floodgate_login_error", getLocale());
+                        disconnectMessage = GeyserLocale.getPlayerLocaleString("geyser.network.remote.floodgate_login_error", locale());
                         if (geyser.getPlatformType() == PlatformType.STANDALONE) {
                             geyser.getLogger().warning(GeyserLocale.getLocaleStringLog("geyser.network.remote.floodgate_login_error_standalone"));
                         }
                     }
                 } else if (cause instanceof ConnectException) {
                     // Server is offline, probably
-                    disconnectMessage = GeyserLocale.getPlayerLocaleString("geyser.network.remote.server_offline", getLocale());
+                    disconnectMessage = GeyserLocale.getPlayerLocaleString("geyser.network.remote.server_offline", locale());
                 } else {
                     disconnectMessage = MessageTranslator.convertMessageLenient(event.getReason());
                 }
@@ -1006,7 +1004,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
                 if (downstream instanceof LocalSession) {
                     geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.remote.disconnect_internal", authData.name(), disconnectMessage));
                 } else {
-                    geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.remote.disconnect", authData.name(), remoteAddress, disconnectMessage));
+                    geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.remote.disconnect", authData.name(), remoteServer.address(), disconnectMessage));
                 }
                 if (cause != null) {
                     cause.printStackTrace();
@@ -1074,7 +1072,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
             try {
                 runnable.run();
             } catch (Throwable e) {
-                geyser.getLogger().error("Error thrown in " + this.name() + "'s event loop!", e);
+                geyser.getLogger().error("Error thrown in " + this.bedrockUsername() + "'s event loop!", e);
             }
         });
     }
@@ -1087,7 +1085,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
             try {
                 runnable.run();
             } catch (Throwable e) {
-                geyser.getLogger().error("Error thrown in " + this.name() + "'s event loop!", e);
+                geyser.getLogger().error("Error thrown in " + this.bedrockUsername() + "'s event loop!", e);
             }
         }, duration, timeUnit);
     }
@@ -1330,32 +1328,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
 
     @Override
     public String name() {
-        return authData.name();
-    }
-
-    @Override
-    public UUID uuid() {
-        return authData.uuid();
-    }
-
-    @Override
-    public String xuid() {
-        return authData.xuid();
-    }
-
-    @SuppressWarnings("ConstantConditions") // Need to enforce the parameter annotations
-    @Override
-    public boolean transfer(@NonNull String address, @IntRange(from = 0, to = 65535) int port) {
-        if (address == null || address.isBlank()) {
-            throw new IllegalArgumentException("Server address cannot be null or blank");
-        } else if (port < 0 || port > 65535) {
-            throw new IllegalArgumentException("Server port must be between 0 and 65535, was " + port);
-        }
-        TransferPacket transferPacket = new TransferPacket();
-        transferPacket.setAddress(address);
-        transferPacket.setPort(port);
-        sendUpstreamPacket(transferPacket);
-        return true;
+        return null;
     }
 
     @Override
@@ -1377,7 +1350,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
     }
 
     @Override
-    public String getLocale() {
+    public String locale() {
         return clientData.getLanguageCode();
     }
 
@@ -1408,12 +1381,14 @@ public class GeyserSession implements GeyserConnection, CommandSender {
         return this.upstream.getAddress();
     }
 
-    public void sendForm(Form form) {
+    public boolean sendForm(@NonNull Form form) {
         formCache.showForm(form);
+        return true;
     }
 
-    public void sendForm(FormBuilder<?, ?, ?> formBuilder) {
+    public boolean sendForm(@NonNull FormBuilder<?, ?, ?> formBuilder) {
         formCache.showForm(formBuilder.build());
+        return true;
     }
 
     /**
@@ -1474,7 +1449,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
         startGamePacket.setFromWorldTemplate(false);
         startGamePacket.setWorldTemplateOptionLocked(false);
 
-        String serverName = geyser.getConfig().getBedrock().getServerName();
+        String serverName = geyser.getConfig().getBedrock().serverName();
         startGamePacket.setLevelId(serverName);
         startGamePacket.setLevelName(serverName);
 
@@ -1482,7 +1457,9 @@ public class GeyserSession implements GeyserConnection, CommandSender {
         // startGamePacket.setCurrentTick(0);
         startGamePacket.setEnchantmentSeed(0);
         startGamePacket.setMultiplayerCorrelationId("");
+
         startGamePacket.setItemEntries(this.itemMappings.getItemEntries());
+
         startGamePacket.setVanillaVersion("*");
         startGamePacket.setInventoriesServerAuthoritative(true);
         startGamePacket.setServerEngine(""); // Do we want to fill this in?
@@ -1645,7 +1622,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
         boolean spectator = gameMode == GameMode.SPECTATOR;
         boolean worldImmutable = gameMode == GameMode.ADVENTURE || spectator;
 
-        if (org.geysermc.geyser.network.MinecraftProtocol.supports1_19_10(this)) {
+        if (GameProtocol.supports1_19_10(this)) {
             UpdateAdventureSettingsPacket adventureSettingsPacket = new UpdateAdventureSettingsPacket();
             adventureSettingsPacket.setNoMvP(false);
             adventureSettingsPacket.setNoPvM(false);
@@ -1755,7 +1732,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
      * Send a packet to the server to indicate client render distance, locale, skin parts, and hand preference.
      */
     public void sendJavaClientSettings() {
-        ServerboundClientInformationPacket clientSettingsPacket = new ServerboundClientInformationPacket(getLocale(),
+        ServerboundClientInformationPacket clientSettingsPacket = new ServerboundClientInformationPacket(locale(),
                 getRenderDistance(), ChatVisibility.FULL, true, SKIN_PARTS,
                 HandPreference.RIGHT_HAND, false, true);
         sendDownstreamPacket(clientSettingsPacket);
@@ -1766,7 +1743,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
      *
      * @param statistics Updated statistics values
      */
-    public void updateStatistics(@Nonnull Object2IntMap<Statistic> statistics) {
+    public void updateStatistics(@NonNull Object2IntMap<Statistic> statistics) {
         if (this.statistics.isEmpty()) {
             // Initialize custom statistics to 0, so that they appear in the form
             for (CustomStatistic customStatistic : CustomStatistic.values()) {
@@ -1852,4 +1829,69 @@ public class GeyserSession implements GeyserConnection, CommandSender {
     public MinecraftCodecHelper getCodecHelper() {
         return (MinecraftCodecHelper) this.downstream.getCodecHelper();
     }
+
+    @Override
+    public String bedrockUsername() {
+        return authData.name();
+    }
+
+    @Override
+    public @MonotonicNonNull String javaUsername() {
+        return playerEntity.getUsername();
+    }
+
+    @Override
+    public UUID javaUuid() {
+        return playerEntity.getUuid();
+    }
+
+    @Override
+    public String xuid() {
+        return authData.xuid();
+    }
+
+    @Override
+    public @NonNull String version() {
+        return clientData.getGameVersion();
+    }
+
+    @Override
+    public @NonNull BedrockPlatform platform() {
+        return BedrockPlatform.values()[clientData.getDeviceOs().ordinal()]; //todo
+    }
+
+    @Override
+    public @NonNull String languageCode() {
+        return locale();
+    }
+
+    @Override
+    public @NonNull UiProfile uiProfile() {
+        return UiProfile.values()[clientData.getUiProfile().ordinal()]; //todo
+    }
+
+    @Override
+    public @NonNull InputMode inputMode() {
+        return InputMode.values()[clientData.getCurrentInputMode().ordinal()]; //todo
+    }
+
+    @Override
+    public boolean isLinked() {
+        return false; //todo
+    }
+
+    @SuppressWarnings("ConstantConditions") // Need to enforce the parameter annotations
+    @Override
+    public boolean transfer(@NonNull String address, @IntRange(from = 0, to = 65535) int port) {
+        if (address == null || address.isBlank()) {
+            throw new IllegalArgumentException("Server address cannot be null or blank");
+        } else if (port < 0 || port > 65535) {
+            throw new IllegalArgumentException("Server port must be between 0 and 65535, was " + port);
+        }
+        TransferPacket transferPacket = new TransferPacket();
+        transferPacket.setAddress(address);
+        transferPacket.setPort(port);
+        sendUpstreamPacket(transferPacket);
+        return true;
+    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/session/SessionManager.java b/core/src/main/java/org/geysermc/geyser/session/SessionManager.java
index 8cfc73d7e..02940e00c 100644
--- a/core/src/main/java/org/geysermc/geyser/session/SessionManager.java
+++ b/core/src/main/java/org/geysermc/geyser/session/SessionManager.java
@@ -28,6 +28,7 @@ package org.geysermc.geyser.session;
 import com.google.common.collect.ImmutableList;
 import lombok.AccessLevel;
 import lombok.Getter;
+import lombok.NonNull;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.*;
@@ -61,12 +62,23 @@ public final class SessionManager {
     }
 
     public void removeSession(GeyserSession session) {
-        if (sessions.remove(session.getPlayerEntity().getUuid()) == null) {
+        UUID uuid = session.getPlayerEntity().getUuid();
+        if (uuid == null || sessions.remove(uuid) == null) {
             // Connection was likely pending
             pendingSessions.remove(session);
         }
     }
 
+    public GeyserSession sessionByXuid(@NonNull String xuid) {
+        Objects.requireNonNull(xuid);
+        for (GeyserSession session : sessions.values()) {
+            if (session.xuid().equals(xuid)) {
+                return session;
+            }
+        }
+        return null;
+    }
+
     /**
      * Creates a new, immutable list containing all pending and active sessions.
      */
@@ -80,7 +92,7 @@ public final class SessionManager {
     public void disconnectAll(String message) {
         Collection<GeyserSession> sessions = getAllSessions();
         for (GeyserSession session : sessions) {
-            session.disconnect(GeyserLocale.getPlayerLocaleString(message, session.getLocale()));
+            session.disconnect(GeyserLocale.getPlayerLocaleString(message, session.locale()));
         }
     }
 
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/AdvancementsCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/AdvancementsCache.java
index f5801ed2b..00b186292 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/AdvancementsCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/AdvancementsCache.java
@@ -73,13 +73,13 @@ public class AdvancementsCache {
     public void buildAndShowMenuForm() {
         SimpleForm.Builder builder =
                 SimpleForm.builder()
-                        .translator(MinecraftLocale::getLocaleString, session.getLocale())
+                        .translator(MinecraftLocale::getLocaleString, session.locale())
                         .title("gui.advancements");
 
         List<String> rootAdvancementIds = new ArrayList<>();
         for (Map.Entry<String, GeyserAdvancement> advancement : storedAdvancements.entrySet()) {
             if (advancement.getValue().getParentId() == null) { // No parent means this is a root advancement
-                builder.button(MessageTranslator.convertMessage(advancement.getValue().getDisplayData().getTitle(), session.getLocale()));
+                builder.button(MessageTranslator.convertMessage(advancement.getValue().getDisplayData().getTitle(), session.locale()));
                 rootAdvancementIds.add(advancement.getKey());
             }
         }
@@ -111,7 +111,7 @@ public class AdvancementsCache {
      */
     public void buildAndShowListForm() {
         GeyserAdvancement categoryAdvancement = storedAdvancements.get(currentAdvancementCategoryId);
-        String language = session.getLocale();
+        String language = session.locale();
 
         SimpleForm.Builder builder =
                 SimpleForm.builder()
@@ -160,7 +160,7 @@ public class AdvancementsCache {
      */
     public void buildAndShowInfoForm(GeyserAdvancement advancement) {
         // Cache language for easier access
-        String language = session.getLocale();
+        String language = session.locale();
 
         String earned = isEarned(advancement) ? "yes" : "no";
 
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/BossBar.java b/core/src/main/java/org/geysermc/geyser/session/cache/BossBar.java
index 7cfeaa165..cd1bc4c98 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/BossBar.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/BossBar.java
@@ -57,7 +57,7 @@ public class BossBar {
         BossEventPacket bossEventPacket = new BossEventPacket();
         bossEventPacket.setBossUniqueEntityId(entityId);
         bossEventPacket.setAction(BossEventPacket.Action.CREATE);
-        bossEventPacket.setTitle(MessageTranslator.convertMessage(title, session.getLocale()));
+        bossEventPacket.setTitle(MessageTranslator.convertMessage(title, session.locale()));
         bossEventPacket.setHealthPercentage(health);
         bossEventPacket.setColor(color);
         bossEventPacket.setOverlay(overlay);
@@ -71,7 +71,7 @@ public class BossBar {
         BossEventPacket bossEventPacket = new BossEventPacket();
         bossEventPacket.setBossUniqueEntityId(entityId);
         bossEventPacket.setAction(BossEventPacket.Action.UPDATE_NAME);
-        bossEventPacket.setTitle(MessageTranslator.convertMessage(title, session.getLocale()));
+        bossEventPacket.setTitle(MessageTranslator.convertMessage(title, session.locale()));
 
         session.sendUpstreamPacket(bossEventPacket);
     }
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/ChunkCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/ChunkCache.java
index 91d6b33d6..d2c1415a3 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/ChunkCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/ChunkCache.java
@@ -30,10 +30,10 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
 import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
 import lombok.Getter;
 import lombok.Setter;
-import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.level.BedrockDimension;
 import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.level.chunk.GeyserChunk;
-import org.geysermc.geyser.level.BedrockDimension;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.MathUtils;
 
 public class ChunkCache {
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/PistonCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/PistonCache.java
index 03785de16..07ccd6280 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/PistonCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/PistonCache.java
@@ -34,10 +34,10 @@ import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
 import org.geysermc.geyser.entity.type.player.SessionPlayerEntity;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.level.physics.BoundingBox;
-import org.geysermc.geyser.translator.level.block.entity.PistonBlockEntity;
 import org.geysermc.geyser.level.physics.Axis;
+import org.geysermc.geyser.level.physics.BoundingBox;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.level.block.entity.PistonBlockEntity;
 
 import java.util.Map;
 
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/TagCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/TagCache.java
index ac0c93204..9cd5b2ef6 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/TagCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/TagCache.java
@@ -89,7 +89,7 @@ public class TagCache {
         boolean emulatePost1_18Logic = convertableToMud != null && convertableToMud.length != 0;
         session.setEmulatePost1_18Logic(emulatePost1_18Logic);
         if (logger.isDebug()) {
-            logger.debug("Emulating post 1.18 block predication logic for " + session.name() + "? " + emulatePost1_18Logic);
+            logger.debug("Emulating post 1.18 block predication logic for " + session.bedrockUsername() + "? " + emulatePost1_18Logic);
         }
 
         Map<String, int[]> itemTags = packet.getTags().get("minecraft:item");
@@ -104,7 +104,7 @@ public class TagCache {
         boolean emulatePost1_13Logic = itemTags.get("minecraft:signs").length > 1;
         session.setEmulatePost1_13Logic(emulatePost1_13Logic);
         if (logger.isDebug()) {
-            logger.debug("Emulating post 1.13 villager logic for " + session.name() + "? " + emulatePost1_13Logic);
+            logger.debug("Emulating post 1.13 villager logic for " + session.bedrockUsername() + "? " + emulatePost1_13Logic);
         }
     }
 
diff --git a/core/src/main/java/org/geysermc/geyser/skin/FloodgateSkinUploader.java b/core/src/main/java/org/geysermc/geyser/skin/FloodgateSkinUploader.java
index 7a800890b..7b6dacd16 100644
--- a/core/src/main/java/org/geysermc/geyser/skin/FloodgateSkinUploader.java
+++ b/core/src/main/java/org/geysermc/geyser/skin/FloodgateSkinUploader.java
@@ -31,12 +31,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import lombok.Getter;
 import org.geysermc.floodgate.pluginmessage.PluginMessageChannels;
+import org.geysermc.floodgate.util.WebsocketEventType;
+import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.util.PluginMessageUtils;
-import org.geysermc.floodgate.util.WebsocketEventType;
 import org.java_websocket.client.WebSocketClient;
 import org.java_websocket.handshake.ServerHandshake;
 
@@ -114,7 +114,7 @@ public final class FloodgateSkinUploader {
 
                             if (session != null) {
                                 if (!node.get("success").asBoolean()) {
-                                    logger.info("Failed to upload skin for " + session.name());
+                                    logger.info("Failed to upload skin for " + session.bedrockUsername());
                                     return;
                                 }
 
diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java
index 4eb92c3ac..992835a2b 100644
--- a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java
+++ b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java
@@ -33,9 +33,9 @@ import com.nukkitx.protocol.bedrock.data.skin.ImageData;
 import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin;
 import com.nukkitx.protocol.bedrock.packet.PlayerListPacket;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.session.auth.BedrockClientData;
 import org.geysermc.geyser.text.GeyserLocale;
 
@@ -286,7 +286,7 @@ public class SkinManager {
 
             String skinUrl = isAlex ? SkinProvider.EMPTY_SKIN_ALEX.getTextureUrl() : SkinProvider.EMPTY_SKIN.getTextureUrl();
             String capeUrl = SkinProvider.EMPTY_CAPE.getTextureUrl();
-            if (("steve".equals(skinUrl) || "alex".equals(skinUrl)) && GeyserImpl.getInstance().getConfig().getRemote().getAuthType() != AuthType.ONLINE) {
+            if (("steve".equals(skinUrl) || "alex".equals(skinUrl)) && GeyserImpl.getInstance().getConfig().getRemote().authType() != AuthType.ONLINE) {
                 GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid);
 
                 if (session != null) {
diff --git a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java
index 93150942c..94ad5eead 100644
--- a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java
+++ b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java
@@ -30,7 +30,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.Getter;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.util.FileUtils;
 import org.geysermc.geyser.util.WebUtils;
 
@@ -71,7 +71,7 @@ public class MinecraftLocale {
                 // Get the url for the latest version of the games manifest
                 String latestInfoURL = "";
                 for (Version version : versionManifest.getVersions()) {
-                    if (version.getId().equals(MinecraftProtocol.getJavaCodec().getMinecraftVersion())) {
+                    if (version.getId().equals(GameProtocol.getJavaCodec().getMinecraftVersion())) {
                         latestInfoURL = version.getUrl();
                         break;
                     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/collision/BlockCollision.java b/core/src/main/java/org/geysermc/geyser/translator/collision/BlockCollision.java
index 3d2cc563e..1dc6cd4e9 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/collision/BlockCollision.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/collision/BlockCollision.java
@@ -29,10 +29,10 @@ import com.nukkitx.math.vector.Vector3d;
 import com.nukkitx.math.vector.Vector3i;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
-import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.level.physics.Axis;
 import org.geysermc.geyser.level.physics.BoundingBox;
 import org.geysermc.geyser.level.physics.CollisionManager;
-import org.geysermc.geyser.level.physics.Axis;
+import org.geysermc.geyser.session.GeyserSession;
 
 @EqualsAndHashCode
 public class BlockCollision {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/collision/DoorCollision.java b/core/src/main/java/org/geysermc/geyser/translator/collision/DoorCollision.java
index c101fcdfb..b47b187c4 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/collision/DoorCollision.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/collision/DoorCollision.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.translator.collision;
 
 import lombok.EqualsAndHashCode;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.physics.BoundingBox;
+import org.geysermc.geyser.session.GeyserSession;
 
 @EqualsAndHashCode(callSuper = true)
 @CollisionRemapper(regex = "_door$", usesParams = true, passDefaultBoxes = true)
diff --git a/core/src/main/java/org/geysermc/geyser/translator/collision/ScaffoldingCollision.java b/core/src/main/java/org/geysermc/geyser/translator/collision/ScaffoldingCollision.java
index 2aa74499a..dfbd1c8b8 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/collision/ScaffoldingCollision.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/collision/ScaffoldingCollision.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.translator.collision;
 
 import lombok.EqualsAndHashCode;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.physics.BoundingBox;
+import org.geysermc.geyser.session.GeyserSession;
 
 /**
  * In order for scaffolding to work on Bedrock, entity flags need to be sent to the player
diff --git a/core/src/main/java/org/geysermc/geyser/translator/collision/SnowCollision.java b/core/src/main/java/org/geysermc/geyser/translator/collision/SnowCollision.java
index 998e15ded..fb83e357d 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/collision/SnowCollision.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/collision/SnowCollision.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.translator.collision;
 
 import lombok.EqualsAndHashCode;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.physics.BoundingBox;
+import org.geysermc.geyser.session.GeyserSession;
 
 @EqualsAndHashCode(callSuper = true)
 @CollisionRemapper(regex = "^snow$", passDefaultBoxes = true, usesParams = true)
diff --git a/core/src/main/java/org/geysermc/geyser/translator/collision/TrapdoorCollision.java b/core/src/main/java/org/geysermc/geyser/translator/collision/TrapdoorCollision.java
index 0660c3cf6..836c05711 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/collision/TrapdoorCollision.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/collision/TrapdoorCollision.java
@@ -26,9 +26,9 @@
 package org.geysermc.geyser.translator.collision;
 
 import lombok.EqualsAndHashCode;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.physics.BoundingBox;
 import org.geysermc.geyser.level.physics.CollisionManager;
+import org.geysermc.geyser.session.GeyserSession;
 
 @EqualsAndHashCode(callSuper = true)
 @CollisionRemapper(regex = "_trapdoor$", usesParams = true, passDefaultBoxes = true)
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/AbstractBlockInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/AbstractBlockInventoryTranslator.java
index bf806bd06..c1fabcf0f 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/AbstractBlockInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/AbstractBlockInventoryTranslator.java
@@ -27,10 +27,10 @@ package org.geysermc.geyser.translator.inventory;
 
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.holder.BlockInventoryHolder;
 import org.geysermc.geyser.inventory.holder.InventoryHolder;
 import org.geysermc.geyser.inventory.updater.InventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 
 /**
  * Provided as a base for any inventory that requires a block for opening it
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/AnvilInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/AnvilInventoryTranslator.java
index e56586b14..956fdeae0 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/AnvilInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/AnvilInventoryTranslator.java
@@ -34,11 +34,11 @@ import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequ
 import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.StackRequestActionType;
 import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
 import org.geysermc.geyser.inventory.AnvilContainer;
+import org.geysermc.geyser.inventory.BedrockContainerSlot;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.PlayerInventory;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.inventory.BedrockContainerSlot;
 import org.geysermc.geyser.inventory.updater.AnvilInventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 
 import java.util.Objects;
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/BaseInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/BaseInventoryTranslator.java
index 8016ca24f..9b6e6df56 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/BaseInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/BaseInventoryTranslator.java
@@ -28,12 +28,8 @@ package org.geysermc.geyser.translator.inventory;
 import com.github.steveice10.mc.protocol.data.game.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
-import org.geysermc.geyser.inventory.Container;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.inventory.PlayerInventory;
+import org.geysermc.geyser.inventory.*;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.inventory.BedrockContainerSlot;
-import org.geysermc.geyser.inventory.SlotType;
 
 public abstract class BaseInventoryTranslator extends InventoryTranslator {
     public BaseInventoryTranslator(int size) {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/BrewingInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/BrewingInventoryTranslator.java
index b12cd8354..69ad41f97 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/BrewingInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/BrewingInventoryTranslator.java
@@ -29,10 +29,10 @@ import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
 import com.nukkitx.protocol.bedrock.packet.ContainerSetDataPacket;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.BedrockContainerSlot;
+import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.updater.ContainerInventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 
 public class BrewingInventoryTranslator extends AbstractBlockInventoryTranslator {
     public BrewingInventoryTranslator() {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/CartographyInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/CartographyInventoryTranslator.java
index 226abe157..c796ab5e3 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/CartographyInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/CartographyInventoryTranslator.java
@@ -28,13 +28,9 @@ package org.geysermc.geyser.translator.inventory;
 import com.github.steveice10.mc.protocol.data.game.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
-import org.geysermc.geyser.inventory.CartographyContainer;
-import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.inventory.PlayerInventory;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.inventory.BedrockContainerSlot;
+import org.geysermc.geyser.inventory.*;
 import org.geysermc.geyser.inventory.updater.UIInventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 
 public class CartographyInventoryTranslator extends AbstractBlockInventoryTranslator {
     public CartographyInventoryTranslator() {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/Generic3X3InventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/Generic3X3InventoryTranslator.java
index 9f7a52107..3ca8f165f 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/Generic3X3InventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/Generic3X3InventoryTranslator.java
@@ -28,12 +28,12 @@ package org.geysermc.geyser.translator.inventory;
 import com.github.steveice10.mc.protocol.data.game.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.packet.ContainerOpenPacket;
+import org.geysermc.geyser.inventory.BedrockContainerSlot;
 import org.geysermc.geyser.inventory.Generic3X3Container;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.PlayerInventory;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.inventory.BedrockContainerSlot;
 import org.geysermc.geyser.inventory.updater.ContainerInventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 
 /**
  * Droppers and dispensers
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java
index 394a394ed..8c7ee1c80 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java
@@ -201,7 +201,7 @@ public abstract class InventoryTranslator {
                     TransferStackRequestActionData transferAction = (TransferStackRequestActionData) action;
                     if (!(checkNetId(session, inventory, transferAction.getSource()) && checkNetId(session, inventory, transferAction.getDestination()))) {
                         if (session.getGeyser().getConfig().isDebugMode()) {
-                            session.getGeyser().getLogger().error("DEBUG: About to reject TAKE/PLACE request made by " + session.name());
+                            session.getGeyser().getLogger().error("DEBUG: About to reject TAKE/PLACE request made by " + session.bedrockUsername());
                             dumpStackRequestDetails(session, inventory, transferAction.getSource(), transferAction.getDestination());
                         }
                         return rejectRequest(request);
@@ -292,7 +292,7 @@ public abstract class InventoryTranslator {
 
                     if (!(checkNetId(session, inventory, source) && checkNetId(session, inventory, destination))) {
                         if (session.getGeyser().getConfig().isDebugMode()) {
-                            session.getGeyser().getLogger().error("DEBUG: About to reject SWAP request made by " + session.name());
+                            session.getGeyser().getLogger().error("DEBUG: About to reject SWAP request made by " + session.bedrockUsername());
                             dumpStackRequestDetails(session, inventory, source, destination);
                         }
                         return rejectRequest(request);
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/LecternInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/LecternInventoryTranslator.java
index f6d24363a..7b2f861f5 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/LecternInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/LecternInventoryTranslator.java
@@ -39,8 +39,8 @@ import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.LecternContainer;
 import org.geysermc.geyser.inventory.PlayerInventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.updater.InventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.BlockEntityUtils;
 import org.geysermc.geyser.util.InventoryUtils;
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/MerchantInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/MerchantInventoryTranslator.java
index 031fb606e..5e9c99ae9 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/MerchantInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/MerchantInventoryTranslator.java
@@ -37,16 +37,12 @@ import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.AutoCraft
 import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.CraftRecipeStackRequestActionData;
 import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket;
 import com.nukkitx.protocol.bedrock.packet.SetEntityLinkPacket;
-import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.EntityDefinitions;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.inventory.MerchantContainer;
-import org.geysermc.geyser.inventory.PlayerInventory;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.inventory.BedrockContainerSlot;
-import org.geysermc.geyser.inventory.SlotType;
+import org.geysermc.geyser.entity.type.Entity;
+import org.geysermc.geyser.inventory.*;
 import org.geysermc.geyser.inventory.updater.InventoryUpdater;
 import org.geysermc.geyser.inventory.updater.UIInventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.InventoryUtils;
 
 import java.util.concurrent.TimeUnit;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java
index e2349e5a5..ee7d6a7c6 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java
@@ -371,7 +371,7 @@ public class PlayerInventoryTranslator extends InventoryTranslator {
                     }
                 }
                 default -> {
-                    session.getGeyser().getLogger().error("Unknown crafting state induced by " + session.name());
+                    session.getGeyser().getLogger().error("Unknown crafting state induced by " + session.bedrockUsername());
                     return rejectRequest(request);
                 }
             }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/ShulkerInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/ShulkerInventoryTranslator.java
index f77ff2229..a055d3b5d 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/ShulkerInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/ShulkerInventoryTranslator.java
@@ -32,13 +32,13 @@ import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.packet.BlockEntityDataPacket;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.BedrockContainerSlot;
+import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.holder.BlockInventoryHolder;
 import org.geysermc.geyser.inventory.updater.ContainerInventoryUpdater;
-import org.geysermc.geyser.translator.level.block.entity.BlockEntityTranslator;
 import org.geysermc.geyser.registry.Registries;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.level.block.entity.BlockEntityTranslator;
 
 public class ShulkerInventoryTranslator extends AbstractBlockInventoryTranslator {
     public ShulkerInventoryTranslator() {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/ChestInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/ChestInventoryTranslator.java
index 65d789c0b..548e9e6e3 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/ChestInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/ChestInventoryTranslator.java
@@ -26,12 +26,12 @@
 package org.geysermc.geyser.translator.inventory.chest;
 
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.BedrockContainerSlot;
-import org.geysermc.geyser.translator.inventory.BaseInventoryTranslator;
+import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.updater.ChestInventoryUpdater;
 import org.geysermc.geyser.inventory.updater.InventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.BaseInventoryTranslator;
 
 public abstract class ChestInventoryTranslator extends BaseInventoryTranslator {
     private final InventoryUpdater updater;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/DoubleChestInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/DoubleChestInventoryTranslator.java
index ec5c882c3..0dd8553fd 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/DoubleChestInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/DoubleChestInventoryTranslator.java
@@ -35,11 +35,11 @@ import com.nukkitx.protocol.bedrock.packet.ContainerOpenPacket;
 import com.nukkitx.protocol.bedrock.packet.UpdateBlockPacket;
 import org.geysermc.geyser.inventory.Container;
 import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.level.block.DoubleChestValue;
-import org.geysermc.geyser.translator.level.block.entity.DoubleChestBlockEntityTranslator;
 import org.geysermc.geyser.registry.BlockRegistries;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.level.block.entity.DoubleChestBlockEntityTranslator;
 
 public class DoubleChestInventoryTranslator extends ChestInventoryTranslator {
     private final int defaultJavaBlockState;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/SingleChestInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/SingleChestInventoryTranslator.java
index 4d158c4fe..41e7bfb9f 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/SingleChestInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/chest/SingleChestInventoryTranslator.java
@@ -27,9 +27,9 @@ package org.geysermc.geyser.translator.inventory.chest;
 
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.holder.BlockInventoryHolder;
 import org.geysermc.geyser.inventory.holder.InventoryHolder;
+import org.geysermc.geyser.session.GeyserSession;
 
 public class SingleChestInventoryTranslator extends ChestInventoryTranslator {
     private final InventoryHolder holder;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/furnace/AbstractFurnaceInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/furnace/AbstractFurnaceInventoryTranslator.java
index 6794b17e4..764ab0a33 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/furnace/AbstractFurnaceInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/furnace/AbstractFurnaceInventoryTranslator.java
@@ -28,12 +28,12 @@ package org.geysermc.geyser.translator.inventory.furnace;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerType;
 import com.nukkitx.protocol.bedrock.packet.ContainerSetDataPacket;
-import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.inventory.BedrockContainerSlot;
+import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.SlotType;
-import org.geysermc.geyser.translator.inventory.AbstractBlockInventoryTranslator;
 import org.geysermc.geyser.inventory.updater.ContainerInventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.AbstractBlockInventoryTranslator;
 
 public abstract class AbstractFurnaceInventoryTranslator extends AbstractBlockInventoryTranslator {
     AbstractFurnaceInventoryTranslator(String javaBlockIdentifier, ContainerType containerType) {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/AbstractHorseInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/AbstractHorseInventoryTranslator.java
index 064793d29..0ad6ba137 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/AbstractHorseInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/AbstractHorseInventoryTranslator.java
@@ -26,10 +26,10 @@
 package org.geysermc.geyser.translator.inventory.horse;
 
 import org.geysermc.geyser.inventory.Inventory;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.inventory.BaseInventoryTranslator;
 import org.geysermc.geyser.inventory.updater.HorseInventoryUpdater;
 import org.geysermc.geyser.inventory.updater.InventoryUpdater;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.BaseInventoryTranslator;
 
 public abstract class AbstractHorseInventoryTranslator extends BaseInventoryTranslator {
     private final InventoryUpdater updater;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/ChestedHorseInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/ChestedHorseInventoryTranslator.java
index 08462249e..4930c6b60 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/ChestedHorseInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/horse/ChestedHorseInventoryTranslator.java
@@ -30,9 +30,9 @@ import com.nukkitx.protocol.bedrock.data.inventory.ContainerSlotType;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData;
 import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket;
+import org.geysermc.geyser.inventory.BedrockContainerSlot;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.inventory.BedrockContainerSlot;
 
 import java.util.Arrays;
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/CompassTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/CompassTranslator.java
index 4c2978082..a0da82648 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/CompassTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/CompassTranslator.java
@@ -30,7 +30,7 @@ import com.github.steveice10.opennbt.tag.builtin.ByteTag;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
@@ -79,8 +79,7 @@ public class CompassTranslator extends ItemTranslator {
 
     @Override
     public List<ItemMapping> getAppliedItems() {
-        return Arrays.stream(Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
-                        .getItems())
+        return Arrays.stream(Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getItems())
                 .filter(entry -> entry.getJavaIdentifier().endsWith("compass"))
                 .collect(Collectors.toList());
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/FilledMapTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/FilledMapTranslator.java
index 3dfa2d82f..b5dbefc3a 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/FilledMapTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/FilledMapTranslator.java
@@ -29,7 +29,7 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
@@ -61,7 +61,7 @@ public class FilledMapTranslator extends ItemTranslator {
     @Override
     public List<ItemMapping> getAppliedItems() {
         return Collections.singletonList(
-                Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
+                Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
                         .getMapping("minecraft:filled_map")
         );
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/GoatHornTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/GoatHornTranslator.java
index 08e8534af..2cb9d7ec7 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/GoatHornTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/GoatHornTranslator.java
@@ -29,7 +29,7 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
@@ -91,7 +91,7 @@ public class GoatHornTranslator extends ItemTranslator {
     @Override
     public List<ItemMapping> getAppliedItems() {
         return Collections.singletonList(
-                Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
+                Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
                         .getMapping("minecraft:goat_horn")
         );
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/ItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/ItemTranslator.java
index 539d20207..b36833cb1 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/ItemTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/ItemTranslator.java
@@ -34,9 +34,12 @@ import com.nukkitx.nbt.NbtType;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.format.NamedTextColor;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.item.custom.CustomItemOptions;
+import org.geysermc.geyser.api.util.TriState;
 import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.registry.type.ItemMapping;
@@ -122,7 +125,7 @@ public abstract class ItemTranslator {
                 }
             }
             if (itemStack.getNbt().isEmpty()) {
-                // Otherwise, seems to causes issues with villagers accepting books, and I don't see how this will break anything else. - Camotoy
+                // Otherwise, seems to cause issues with villagers accepting books, and I don't see how this will break anything else. - Camotoy
                 itemStack = new ItemStack(itemStack.getId(), itemStack.getAmount(), null);
             }
         }
@@ -159,7 +162,7 @@ public abstract class ItemTranslator {
 
         nbt = translateDisplayProperties(session, nbt, bedrockItem);
         if (session.isAdvancedTooltips()) {
-            nbt = addAdvancedTooltips(nbt, bedrockItem, session.getLocale());
+            nbt = addAdvancedTooltips(nbt, bedrockItem, session.locale());
         }
 
         ItemStack itemStack = new ItemStack(stack.getId(), stack.getAmount(), nbt);
@@ -170,6 +173,8 @@ public abstract class ItemTranslator {
             builder.blockRuntimeId(bedrockItem.getBedrockBlockId());
         }
 
+        translateCustomItem(nbt, builder, bedrockItem);
+
         if (nbt != null) {
             // Translate the canDestroy and canPlaceOn Java NBT
             ListTag canDestroy = nbt.get("CanDestroy");
@@ -261,16 +266,23 @@ public abstract class ItemTranslator {
     }
 
     /**
-     * Given an item stack, determine the item mapping that should be applied to Bedrock players.
+     * Given an item stack, determine the Bedrock item ID that should be applied to Bedrock players.
      */
-    @Nonnull
-    public static ItemMapping getBedrockItemMapping(GeyserSession session, @Nonnull GeyserItemStack itemStack) {
+    public static int getBedrockItemId(GeyserSession session, @Nonnull GeyserItemStack itemStack) {
         if (itemStack.isEmpty()) {
-            return ItemMapping.AIR;
+            return ItemMapping.AIR.getJavaId();
         }
         int javaId = itemStack.getJavaId();
-        return ITEM_STACK_TRANSLATORS.getOrDefault(javaId, DEFAULT_TRANSLATOR)
+        ItemMapping mapping = ITEM_STACK_TRANSLATORS.getOrDefault(javaId, DEFAULT_TRANSLATOR)
                 .getItemMapping(javaId, itemStack.getNbt(), session.getItemMappings());
+
+        int customItemId = getCustomItem(itemStack.getNbt(), mapping);
+        if (customItemId == -1) {
+            // No custom item
+            return mapping.getBedrockId();
+        } else {
+            return customItemId;
+        }
     }
 
     private static final ItemTranslator DEFAULT_TRANSLATOR = new ItemTranslator() {
@@ -292,6 +304,10 @@ public abstract class ItemTranslator {
         if (itemStack.getNbt() != null) {
             builder.tag(this.translateNbtToBedrock(itemStack.getNbt()));
         }
+
+        CompoundTag nbt = itemStack.getNbt();
+        translateCustomItem(nbt, builder, mapping);
+
         return builder;
     }
 
@@ -416,7 +432,7 @@ public abstract class ItemTranslator {
         if (object instanceof byte[]) {
             return new ByteArrayTag(name, (byte[]) object);
         }
-        
+
         if (object instanceof Byte) {
             return new ByteTag(name, (byte) object);
         }
@@ -490,7 +506,7 @@ public abstract class ItemTranslator {
                 String name = tagName.getValue();
 
                 // Get the translated name and prefix it with a reset char
-                name = MessageTranslator.convertMessageLenient(name, session.getLocale());
+                name = MessageTranslator.convertMessageLenient(name, session.locale());
 
                 // Add the new name tag
                 display.put(new StringTag("Name", name));
@@ -518,12 +534,54 @@ public abstract class ItemTranslator {
 
             String translationKey = mapping.getTranslationString();
             // Reset formatting since Bedrock defaults to italics
-            display.put(new StringTag("Name", "§r§" + translationColor + MinecraftLocale.getLocaleString(translationKey, session.getLocale())));
+            display.put(new StringTag("Name", "§r§" + translationColor + MinecraftLocale.getLocaleString(translationKey, session.locale())));
         }
 
         return tag;
     }
 
+    /**
+     * Translates the custom model data of an item
+     */
+    private static void translateCustomItem(CompoundTag nbt, ItemData.Builder builder, ItemMapping mapping) {
+        int bedrockId = getCustomItem(nbt, mapping);
+        if (bedrockId != -1) {
+            builder.id(bedrockId);
+        }
+    }
+
+    private static int getCustomItem(CompoundTag nbt, ItemMapping mapping) {
+        if (nbt == null) {
+            return -1;
+        }
+        Object2IntMap<CustomItemOptions> customMappings = mapping.getCustomItemOptions();
+        if (customMappings.isEmpty()) {
+            return -1;
+        }
+        int customModelData = nbt.get("CustomModelData") instanceof IntTag customModelDataTag ? customModelDataTag.getValue() : 0;
+        TriState unbreakable = TriState.fromBoolean(nbt.get("Unbreakable") instanceof ByteTag unbreakableTag && unbreakableTag.getValue() == 1);
+        int damage = nbt.get("Damage") instanceof IntTag damageTag ? damageTag.getValue() : 0;
+        for (Object2IntMap.Entry<CustomItemOptions> mappingTypes : customMappings.object2IntEntrySet()) {
+            CustomItemOptions options = mappingTypes.getKey();
+
+            TriState unbreakableOption = options.unbreakable();
+            if (unbreakableOption == unbreakable) { // Implementation note: if the option is NOT_SET then this comparison will always be false because of how the item unbreaking TriState is created
+                return mappingTypes.getIntValue();
+            }
+
+            OptionalInt customModelDataOption = options.customModelData();
+            if (customModelDataOption.isPresent() && customModelDataOption.getAsInt() == customModelData) {
+                return mappingTypes.getIntValue();
+            }
+
+            OptionalInt damagePredicate = options.damagePredicate();
+            if (damagePredicate.isPresent() && damagePredicate.getAsInt() == damage) {
+                return mappingTypes.getIntValue();
+            }
+        }
+        return -1;
+    }
+
     /**
      * Checks if an {@link ItemStack} is equal to another item stack
      *
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/NbtItemStackTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/NbtItemStackTranslator.java
index bfa7ebc2e..5f22668df 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/NbtItemStackTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/NbtItemStackTranslator.java
@@ -26,8 +26,8 @@
 package org.geysermc.geyser.translator.inventory.item;
 
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 
 public abstract class NbtItemStackTranslator {
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/PotionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/PotionTranslator.java
index bf16af38f..3e814a098 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/PotionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/PotionTranslator.java
@@ -30,8 +30,8 @@ import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.network.MinecraftProtocol;
 import org.geysermc.geyser.inventory.item.Potion;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
@@ -74,8 +74,7 @@ public class PotionTranslator extends ItemTranslator {
 
     @Override
     public List<ItemMapping> getAppliedItems() {
-        return Arrays.stream(Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
-                        .getItems())
+        return Arrays.stream(Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getItems())
                 .filter(entry -> entry.getJavaIdentifier().endsWith("potion"))
                 .collect(Collectors.toList());
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/TippedArrowTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/TippedArrowTranslator.java
index d831ce586..bbf598ecd 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/TippedArrowTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/TippedArrowTranslator.java
@@ -30,8 +30,8 @@ import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.network.MinecraftProtocol;
 import org.geysermc.geyser.inventory.item.TippedArrowPotion;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
@@ -42,7 +42,7 @@ import java.util.stream.Collectors;
 
 @ItemRemapper
 public class TippedArrowTranslator extends ItemTranslator {
-    private static final int TIPPED_ARROW_JAVA_ID = Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
+    private static final int TIPPED_ARROW_JAVA_ID = Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
             .getMapping("minecraft:tipped_arrow")
             .getJavaId();
 
@@ -81,8 +81,7 @@ public class TippedArrowTranslator extends ItemTranslator {
 
     @Override
     public List<ItemMapping> getAppliedItems() {
-        return Arrays.stream(Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
-                        .getItems())
+        return Arrays.stream(Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getItems())
                 .filter(entry -> entry.getJavaIdentifier().contains("arrow")
                         && !entry.getJavaIdentifier().contains("spectral"))
                 .collect(Collectors.toList());
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/AxolotlBucketTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/AxolotlBucketTranslator.java
index c3abf2495..19809c12f 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/AxolotlBucketTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/AxolotlBucketTranslator.java
@@ -28,11 +28,11 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 import com.github.steveice10.opennbt.tag.builtin.ByteTag;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
-import org.geysermc.geyser.text.MinecraftLocale;
 
 @ItemRemapper
 public class AxolotlBucketTranslator extends NbtItemStackTranslator {
@@ -42,7 +42,7 @@ public class AxolotlBucketTranslator extends NbtItemStackTranslator {
         // Bedrock Edition displays the properties of the axolotl. Java does not.
         // To work around this, set the custom name to the Axolotl translation and it's displayed correctly
         itemTag.put(new ByteTag("AppendCustomName", (byte) 1));
-        itemTag.put(new StringTag("CustomName", MinecraftLocale.getLocaleString("entity.minecraft.axolotl", session.getLocale())));
+        itemTag.put(new StringTag("CustomName", MinecraftLocale.getLocaleString("entity.minecraft.axolotl", session.locale())));
         // Boilerplate required so the nametag does not appear as "Bucket of "
         itemTag.put(new StringTag("ColorID", ""));
         itemTag.put(new StringTag("BodyID", ""));
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java
index ed4865411..95dd07f22 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java
@@ -29,7 +29,7 @@ import com.github.steveice10.opennbt.tag.builtin.*;
 import com.nukkitx.nbt.NbtList;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtType;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
@@ -76,8 +76,7 @@ public class BannerTranslator extends NbtItemStackTranslator {
     }
 
     public BannerTranslator() {
-        appliedItems = Arrays.stream(Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion())
-                        .getItems())
+        appliedItems = Arrays.stream(Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getItems())
                 .filter(entry -> entry.getJavaIdentifier().endsWith("banner"))
                 .collect(Collectors.toList());
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BasicItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BasicItemTranslator.java
index a507d02cc..5dcc76b49 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BasicItemTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BasicItemTranslator.java
@@ -26,11 +26,11 @@
 package org.geysermc.geyser.translator.inventory.item.nbt;
 
 import com.github.steveice10.opennbt.tag.builtin.*;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
-import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.translator.text.MessageTranslator;
 import org.geysermc.geyser.util.ItemUtils;
 
 import java.util.ArrayList;
@@ -59,7 +59,7 @@ public class BasicItemTranslator extends NbtItemStackTranslator {
             List<Tag> lore = new ArrayList<>();
             for (Tag tag : listTag.getValue()) {
                 if (!(tag instanceof StringTag)) continue;
-                lore.add(new StringTag("", MessageTranslator.convertMessageLenient(((StringTag) tag).getValue(), session.getLocale())));
+                lore.add(new StringTag("", MessageTranslator.convertMessageLenient(((StringTag) tag).getValue(), session.locale())));
             }
             displayTag.put(new ListTag("Lore", lore));
         }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BookPagesTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BookPagesTranslator.java
index ec741f261..652d804fe 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BookPagesTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BookPagesTranslator.java
@@ -29,11 +29,11 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.ListTag;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
 import org.geysermc.geyser.translator.text.MessageTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/CrossbowTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/CrossbowTranslator.java
index 723798c89..0a4ca0686 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/CrossbowTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/CrossbowTranslator.java
@@ -28,11 +28,11 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack;
 import com.github.steveice10.opennbt.tag.builtin.*;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 @ItemRemapper
 public class CrossbowTranslator extends NbtItemStackTranslator {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantedBookTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantedBookTranslator.java
index 9b1d423c1..ad6c2e9f1 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantedBookTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantedBookTranslator.java
@@ -28,10 +28,10 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.ListTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 @ItemRemapper(priority = 1)
 public class EnchantedBookTranslator extends NbtItemStackTranslator {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantmentTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantmentTranslator.java
index cd6d5d6ff..204981965 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantmentTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/EnchantmentTranslator.java
@@ -27,11 +27,11 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 
 import com.github.steveice10.opennbt.tag.builtin.*;
 import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.inventory.item.Enchantment;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.inventory.item.Enchantment;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkBaseTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkBaseTranslator.java
index 6a4438358..b74a4f61e 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkBaseTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkBaseTranslator.java
@@ -29,8 +29,8 @@ import com.github.steveice10.opennbt.tag.builtin.ByteArrayTag;
 import com.github.steveice10.opennbt.tag.builtin.ByteTag;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.IntArrayTag;
-import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
 import org.geysermc.geyser.level.FireworkColor;
+import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
 import org.geysermc.geyser.util.MathUtils;
 
 /**
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkRocketTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkRocketTranslator.java
index 566b0ac2b..fdf898273 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkRocketTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkRocketTranslator.java
@@ -29,9 +29,9 @@ import com.github.steveice10.opennbt.tag.builtin.ByteTag;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.ListTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
-import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.util.MathUtils;
 
 @ItemRemapper
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkStarTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkStarTranslator.java
index c907375b9..eca3272d1 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkStarTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/FireworkStarTranslator.java
@@ -29,9 +29,9 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.IntArrayTag;
 import com.github.steveice10.opennbt.tag.builtin.IntTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 @ItemRemapper
 public class FireworkStarTranslator extends FireworkBaseTranslator {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LeatherArmorTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LeatherArmorTranslator.java
index 9c74e7123..2fb5ec6cb 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LeatherArmorTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LeatherArmorTranslator.java
@@ -27,10 +27,10 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.IntTag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 import java.util.Arrays;
 import java.util.List;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LodestoneCompassTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LodestoneCompassTranslator.java
index f4b91165d..8025817f7 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LodestoneCompassTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/LodestoneCompassTranslator.java
@@ -25,11 +25,14 @@
 
 package org.geysermc.geyser.translator.inventory.item.nbt;
 
-import com.github.steveice10.opennbt.tag.builtin.*;
+import com.github.steveice10.opennbt.tag.builtin.ByteTag;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.github.steveice10.opennbt.tag.builtin.IntTag;
+import com.github.steveice10.opennbt.tag.builtin.Tag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 @ItemRemapper
 public class LodestoneCompassTranslator extends NbtItemStackTranslator {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/MapItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/MapItemTranslator.java
index 80b22dafb..8fd44ef65 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/MapItemTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/MapItemTranslator.java
@@ -26,10 +26,10 @@
 package org.geysermc.geyser.translator.inventory.item.nbt;
 
 import com.github.steveice10.opennbt.tag.builtin.*;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 
 @ItemRemapper
 public class MapItemTranslator extends NbtItemStackTranslator {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/PlayerHeadTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/PlayerHeadTranslator.java
index 680be00fd..d4975f81a 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/PlayerHeadTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/PlayerHeadTranslator.java
@@ -28,11 +28,11 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
-import org.geysermc.geyser.registry.type.ItemMapping;
-import org.geysermc.geyser.text.MinecraftLocale;
 
 @ItemRemapper
 public class PlayerHeadTranslator extends NbtItemStackTranslator {
@@ -56,7 +56,7 @@ public class PlayerHeadTranslator extends NbtItemStackTranslator {
                 }
                 // Add correct name of player skull
                 // TODO: It's always yellow, even with a custom name. Handle?
-                String displayName = "\u00a7r\u00a7e" + MinecraftLocale.getLocaleString("block.minecraft.player_head.named", session.getLocale()).replace("%s", name.getValue());
+                String displayName = "\u00a7r\u00a7e" + MinecraftLocale.getLocaleString("block.minecraft.player_head.named", session.locale()).replace("%s", name.getValue());
                 if (!itemTag.contains("display")) {
                     itemTag.put(new CompoundTag("display"));
                 }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/ShulkerBoxItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/ShulkerBoxItemTranslator.java
index 1b9acdb96..f95c54e18 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/ShulkerBoxItemTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/ShulkerBoxItemTranslator.java
@@ -27,9 +27,9 @@ package org.geysermc.geyser.translator.inventory.item.nbt;
 
 import com.github.steveice10.mc.protocol.data.game.Identifier;
 import com.github.steveice10.opennbt.tag.builtin.*;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
-import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
 import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
 import org.geysermc.geyser.util.MathUtils;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/TropicalFishBucketTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/TropicalFishBucketTranslator.java
index dbacc75fe..6313dc362 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/TropicalFishBucketTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/TropicalFishBucketTranslator.java
@@ -31,12 +31,12 @@ import net.kyori.adventure.text.format.NamedTextColor;
 import net.kyori.adventure.text.format.Style;
 import net.kyori.adventure.text.format.TextDecoration;
 import org.geysermc.geyser.entity.type.living.animal.TropicalFishEntity;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
-import org.geysermc.geyser.translator.text.MessageTranslator;
-import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.MinecraftLocale;
+import org.geysermc.geyser.translator.inventory.item.ItemRemapper;
+import org.geysermc.geyser.translator.inventory.item.NbtItemStackTranslator;
+import org.geysermc.geyser.translator.text.MessageTranslator;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -50,7 +50,7 @@ public class TropicalFishBucketTranslator extends NbtItemStackTranslator {
     public void translateToBedrock(GeyserSession session, CompoundTag itemTag, ItemMapping mapping) {
         // Prevent name from appearing as "Bucket of"
         itemTag.put(new ByteTag("AppendCustomName", (byte) 1));
-        itemTag.put(new StringTag("CustomName", MinecraftLocale.getLocaleString("entity.minecraft.tropical_fish", session.getLocale())));
+        itemTag.put(new StringTag("CustomName", MinecraftLocale.getLocaleString("entity.minecraft.tropical_fish", session.locale())));
         // Add Java's client side lore tag
         Tag bucketVariantTag = itemTag.get("BucketVariantTag");
         if (bucketVariantTag instanceof IntTag) {
@@ -66,10 +66,10 @@ public class TropicalFishBucketTranslator extends NbtItemStackTranslator {
             int predefinedVariantId = TropicalFishEntity.getPredefinedId(varNumber);
             if (predefinedVariantId != -1) {
                 Component tooltip = Component.translatable("entity.minecraft.tropical_fish.predefined." + predefinedVariantId, LORE_STYLE);
-                lore.add(0, new StringTag("", MessageTranslator.convertMessage(tooltip, session.getLocale())));
+                lore.add(0, new StringTag("", MessageTranslator.convertMessage(tooltip, session.locale())));
             } else {
                 Component typeTooltip = Component.translatable("entity.minecraft.tropical_fish.type." + TropicalFishEntity.getVariantName(varNumber), LORE_STYLE);
-                lore.add(0, new StringTag("", MessageTranslator.convertMessage(typeTooltip, session.getLocale())));
+                lore.add(0, new StringTag("", MessageTranslator.convertMessage(typeTooltip, session.locale())));
 
                 byte baseColor = TropicalFishEntity.getBaseColor(varNumber);
                 byte patternColor = TropicalFishEntity.getPatternColor(varNumber);
@@ -78,7 +78,7 @@ public class TropicalFishBucketTranslator extends NbtItemStackTranslator {
                     colorTooltip = colorTooltip.append(Component.text(", ", LORE_STYLE))
                             .append(Component.translatable("color.minecraft." + TropicalFishEntity.getColorName(patternColor), LORE_STYLE));
                 }
-                lore.add(1, new StringTag("", MessageTranslator.convertMessage(colorTooltip, session.getLocale())));
+                lore.add(1, new StringTag("", MessageTranslator.convertMessage(colorTooltip, session.locale())));
             }
 
             ListTag loreTag = displayTag.get("Lore");
diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java
index 537c93a41..3e47bfc37 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java
@@ -30,18 +30,21 @@ import com.github.steveice10.mc.protocol.data.game.chunk.DataPalette;
 import com.github.steveice10.mc.protocol.data.game.chunk.palette.GlobalPalette;
 import com.github.steveice10.mc.protocol.data.game.chunk.palette.Palette;
 import com.github.steveice10.mc.protocol.data.game.chunk.palette.SingletonPalette;
-import com.github.steveice10.opennbt.tag.builtin.*;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.github.steveice10.opennbt.tag.builtin.IntTag;
+import com.github.steveice10.opennbt.tag.builtin.ListTag;
+import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import it.unimi.dsi.fastutil.ints.Int2IntMap;
 import it.unimi.dsi.fastutil.ints.IntArrayList;
 import it.unimi.dsi.fastutil.ints.IntList;
 import it.unimi.dsi.fastutil.ints.IntLists;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.chunk.BlockStorage;
 import org.geysermc.geyser.level.chunk.GeyserChunkSection;
 import org.geysermc.geyser.level.chunk.bitarray.BitArray;
 import org.geysermc.geyser.level.chunk.bitarray.BitArrayVersion;
 import org.geysermc.geyser.level.chunk.bitarray.SingletonBitArray;
 import org.geysermc.geyser.registry.Registries;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.JavaCodecEntry;
 import org.geysermc.geyser.util.MathUtils;
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/CampfireBlockEntityTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/CampfireBlockEntityTranslator.java
index 53e1af8a5..6ec0effca 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/CampfireBlockEntityTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/CampfireBlockEntityTranslator.java
@@ -30,7 +30,7 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.github.steveice10.opennbt.tag.builtin.ListTag;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtMapBuilder;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 
@@ -48,7 +48,7 @@ public class CampfireBlockEntityTranslator extends BlockEntityTranslator {
 
     protected NbtMap getItem(CompoundTag tag) {
         // TODO: Version independent mappings
-        ItemMapping mapping = Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping((String) tag.get("id").getValue());
+        ItemMapping mapping = Registries.ITEMS.forVersion(GameProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping((String) tag.get("id").getValue());
         NbtMapBuilder tagBuilder = NbtMap.builder()
                 .putString("Name", mapping.getBedrockIdentifier())
                 .putByte("Count", (byte) tag.get("Count").getValue())
diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/DoubleChestBlockEntityTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/DoubleChestBlockEntityTranslator.java
index f5ec3607c..0836b1e59 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/DoubleChestBlockEntityTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/DoubleChestBlockEntityTranslator.java
@@ -29,9 +29,9 @@ import com.github.steveice10.mc.protocol.data.game.level.block.BlockEntityType;
 import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
 import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.nbt.NbtMapBuilder;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.level.block.DoubleChestValue;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.BlockEntityUtils;
 
 /**
diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/FlowerPotBlockEntityTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/FlowerPotBlockEntityTranslator.java
index 845e2e429..ed1a9e82b 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/FlowerPotBlockEntityTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/FlowerPotBlockEntityTranslator.java
@@ -29,8 +29,8 @@ import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.nbt.NbtMapBuilder;
 import com.nukkitx.protocol.bedrock.packet.UpdateBlockPacket;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.BlockEntityUtils;
 
 public class FlowerPotBlockEntityTranslator implements BedrockOnlyBlockEntity {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/PistonBlockEntity.java b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/PistonBlockEntity.java
index f6561ccbe..28e30d6be 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/PistonBlockEntity.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/PistonBlockEntity.java
@@ -37,16 +37,18 @@ import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
 import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
 import lombok.Getter;
 import org.geysermc.common.PlatformType;
+import org.geysermc.geyser.level.block.BlockStateValues;
 import org.geysermc.geyser.level.physics.Axis;
-import org.geysermc.geyser.level.physics.Direction;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.session.cache.PistonCache;
 import org.geysermc.geyser.level.physics.BoundingBox;
 import org.geysermc.geyser.level.physics.CollisionManager;
-import org.geysermc.geyser.translator.collision.BlockCollision;
-import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.level.physics.Direction;
 import org.geysermc.geyser.registry.Registries;
-import org.geysermc.geyser.util.*;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.session.cache.PistonCache;
+import org.geysermc.geyser.translator.collision.BlockCollision;
+import org.geysermc.geyser.util.BlockEntityUtils;
+import org.geysermc.geyser.util.BlockUtils;
+import org.geysermc.geyser.util.ChunkUtils;
 
 import java.util.LinkedList;
 import java.util.Map;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockAdventureSettingsTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockAdventureSettingsTranslator.java
index 641161127..aabc39e12 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockAdventureSettingsTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockAdventureSettingsTranslator.java
@@ -25,10 +25,7 @@
 
 package org.geysermc.geyser.translator.protocol.bedrock;
 
-import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
-import com.github.steveice10.mc.protocol.packet.ingame.serverbound.player.ServerboundPlayerAbilitiesPacket;
 import com.nukkitx.protocol.bedrock.data.AdventureSetting;
-import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
 import com.nukkitx.protocol.bedrock.packet.AdventureSettingsPacket;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
@@ -40,19 +37,6 @@ public class BedrockAdventureSettingsTranslator extends PacketTranslator<Adventu
     @Override
     public void translate(GeyserSession session, AdventureSettingsPacket packet) {
         boolean isFlying = packet.getSettings().contains(AdventureSetting.FLYING);
-        if (!isFlying && session.getGameMode() == GameMode.SPECTATOR) {
-            // We should always be flying in spectator mode
-            session.sendAdventureSettings();
-            return;
-        } else if (isFlying && session.getPlayerEntity().getFlag(EntityFlag.SWIMMING) && session.getCollisionManager().isPlayerInWater()) {
-            // As of 1.18.1, Java Edition cannot fly while in water, but it can fly while crawling
-            // If this isn't present, swimming on a 1.13.2 server and then attempting to fly will put you into a flying/swimming state that is invalid on JE
-            session.sendAdventureSettings();
-            return;
-        }
-
-        session.setFlying(isFlying);
-        ServerboundPlayerAbilitiesPacket abilitiesPacket = new ServerboundPlayerAbilitiesPacket(isFlying);
-        session.sendDownstreamPacket(abilitiesPacket);
+        BedrockRequestAbilityTranslator.handle(session, isFlying);
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java
index 63ccdf729..8d7cbe22b 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java
@@ -29,11 +29,11 @@ import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.protocol.bedrock.packet.BlockPickRequestPacket;
 import org.geysermc.geyser.entity.EntityDefinitions;
 import org.geysermc.geyser.entity.type.ItemFrameEntity;
+import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.level.block.BlockStateValues;
-import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.util.InventoryUtils;
 
 @Translator(packet = BlockPickRequestPacket.class)
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockCommandRequestTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockCommandRequestTranslator.java
index 0aa57b077..3301f7b9f 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockCommandRequestTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockCommandRequestTranslator.java
@@ -28,7 +28,6 @@ package org.geysermc.geyser.translator.protocol.bedrock;
 import com.nukkitx.protocol.bedrock.packet.CommandRequestPacket;
 import org.geysermc.common.PlatformType;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
@@ -40,10 +39,8 @@ public class BedrockCommandRequestTranslator extends PacketTranslator<CommandReq
     @Override
     public void translate(GeyserSession session, CommandRequestPacket packet) {
         String command = packet.getCommand().replace("/", "");
-        CommandManager commandManager = GeyserImpl.getInstance().getCommandManager();
-        if (session.getGeyser().getPlatformType() == PlatformType.STANDALONE && command.trim().startsWith("geyser ") && commandManager.getCommands().containsKey(command.split(" ")[1])) {
-            commandManager.runCommand(session, command);
-        } else {
+        if (!(session.getGeyser().getPlatformType() == PlatformType.STANDALONE
+                && GeyserImpl.getInstance().commandManager().runCommand(session, command))) {
             String message = packet.getCommand().trim();
 
             if (MessageTranslator.isTooLong(message, session)) {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java
index dc7fe854a..a9ef65fb5 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java
@@ -29,10 +29,10 @@ import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.nukkitx.protocol.bedrock.packet.EntityPickRequestPacket;
 import org.geysermc.geyser.entity.type.BoatEntity;
 import org.geysermc.geyser.entity.type.Entity;
+import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.util.InventoryUtils;
 
 import java.util.Locale;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockInventoryTransactionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockInventoryTransactionTranslator.java
index 70f4359da..49eb30973 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockInventoryTransactionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockInventoryTransactionTranslator.java
@@ -54,7 +54,7 @@ import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.PlayerInventory;
 import org.geysermc.geyser.inventory.click.Click;
 import org.geysermc.geyser.level.block.BlockStateValues;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.registry.type.ItemMapping;
 import org.geysermc.geyser.registry.type.ItemMappings;
@@ -467,7 +467,7 @@ public class BedrockInventoryTransactionTranslator extends PacketTranslator<Inve
                                 InteractAction.ATTACK, session.isSneaking());
                         session.sendDownstreamPacket(attackPacket);
 
-                        if (MinecraftProtocol.supports1_19_10(session)) {
+                        if (GameProtocol.supports1_19_10(session)) {
                             // Since 1.19.10, LevelSoundEventPackets are no longer sent by the client when attacking entities
                             CooldownUtils.sendCooldown(session);
                         }
@@ -545,11 +545,11 @@ public class BedrockInventoryTransactionTranslator extends PacketTranslator<Inve
 
     private boolean isIncorrectHeldItem(GeyserSession session, InventoryTransactionPacket packet) {
         int javaSlot = session.getPlayerInventory().getOffsetForHotbar(packet.getHotbarSlot());
-        int expectedItemId = ItemTranslator.getBedrockItemMapping(session, session.getPlayerInventory().getItem(javaSlot)).getBedrockId();
+        int expectedItemId = ItemTranslator.getBedrockItemId(session, session.getPlayerInventory().getItem(javaSlot));
         int heldItemId = packet.getItemInHand() == null ? ItemData.AIR.getId() : packet.getItemInHand().getId();
 
         if (expectedItemId != heldItemId) {
-            session.getGeyser().getLogger().debug(session.name() + "'s held item has desynced! Expected: " + expectedItemId + " Received: " + heldItemId);
+            session.getGeyser().getLogger().debug(session.bedrockUsername() + "'s held item has desynced! Expected: " + expectedItemId + " Received: " + heldItemId);
             session.getGeyser().getLogger().debug("Packet: " + packet);
             return true;
         }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockItemStackRequestTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockItemStackRequestTranslator.java
index 7c28d8af1..1761a3beb 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockItemStackRequestTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockItemStackRequestTranslator.java
@@ -28,9 +28,9 @@ package org.geysermc.geyser.translator.protocol.bedrock;
 import com.nukkitx.protocol.bedrock.packet.ItemStackRequestPacket;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 
 /**
  * The packet sent for server-authoritative-style inventory transactions.
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockMoveEntityAbsoluteTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockMoveEntityAbsoluteTranslator.java
index 0bb3df071..e6390bdba 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockMoveEntityAbsoluteTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockMoveEntityAbsoluteTranslator.java
@@ -28,9 +28,9 @@ package org.geysermc.geyser.translator.protocol.bedrock;
 import com.github.steveice10.mc.protocol.packet.ingame.serverbound.level.ServerboundMoveVehiclePacket;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.packet.MoveEntityAbsolutePacket;
+import org.geysermc.geyser.entity.EntityDefinitions;
 import org.geysermc.geyser.entity.type.BoatEntity;
 import org.geysermc.geyser.entity.type.Entity;
-import org.geysermc.geyser.entity.EntityDefinitions;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockNetworkStackLatencyTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockNetworkStackLatencyTranslator.java
index 0cbaa9e99..876395114 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockNetworkStackLatencyTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockNetworkStackLatencyTranslator.java
@@ -29,11 +29,11 @@ import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundKe
 import com.nukkitx.protocol.bedrock.data.AttributeData;
 import com.nukkitx.protocol.bedrock.packet.NetworkStackLatencyPacket;
 import com.nukkitx.protocol.bedrock.packet.UpdateAttributesPacket;
+import org.geysermc.floodgate.util.DeviceOs;
 import org.geysermc.geyser.entity.attribute.GeyserAttributeType;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.floodgate.util.DeviceOs;
 
 import java.util.Collections;
 import java.util.concurrent.TimeUnit;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockPlayerInputTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockPlayerInputTranslator.java
index fb2435cea..88cfcdff7 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockPlayerInputTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockPlayerInputTranslator.java
@@ -29,9 +29,9 @@ import com.github.steveice10.mc.protocol.packet.ingame.serverbound.level.Serverb
 import com.github.steveice10.mc.protocol.packet.ingame.serverbound.level.ServerboundPlayerInputPacket;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.packet.PlayerInputPacket;
+import org.geysermc.geyser.entity.EntityDefinitions;
 import org.geysermc.geyser.entity.type.BoatEntity;
 import org.geysermc.geyser.entity.type.Entity;
-import org.geysermc.geyser.entity.EntityDefinitions;
 import org.geysermc.geyser.entity.type.living.animal.horse.AbstractHorseEntity;
 import org.geysermc.geyser.entity.type.living.animal.horse.LlamaEntity;
 import org.geysermc.geyser.session.GeyserSession;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockRequestAbilityTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockRequestAbilityTranslator.java
new file mode 100644
index 000000000..fe8150d40
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockRequestAbilityTranslator.java
@@ -0,0 +1,73 @@
+/*
+ * 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.translator.protocol.bedrock;
+
+import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
+import com.github.steveice10.mc.protocol.packet.ingame.serverbound.player.ServerboundPlayerAbilitiesPacket;
+import com.nukkitx.protocol.bedrock.data.Ability;
+import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
+import com.nukkitx.protocol.bedrock.packet.RequestAbilityPacket;
+import org.geysermc.geyser.network.GameProtocol;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.translator.protocol.Translator;
+
+/**
+ * Replaces the AdventureSettingsPacket completely in 1.19.30.
+ */
+@Translator(packet = RequestAbilityPacket.class)
+public class BedrockRequestAbilityTranslator extends PacketTranslator<RequestAbilityPacket> {
+
+    @Override
+    public void translate(GeyserSession session, RequestAbilityPacket packet) {
+        // Gatekeep to 1.19.30 so older versions don't fire twice
+        if (!GameProtocol.supports1_19_30(session)) {
+            return;
+        }
+        
+        if (packet.getAbility() == Ability.FLYING) {
+            handle(session, packet.isBoolValue());
+        }
+    }
+
+    //FIXME remove after pre-1.19.30 support is dropped and merge into main method
+    static void handle(GeyserSession session, boolean isFlying) {
+        if (!isFlying && session.getGameMode() == GameMode.SPECTATOR) {
+            // We should always be flying in spectator mode
+            session.sendAdventureSettings();
+            return;
+        } else if (isFlying && session.getPlayerEntity().getFlag(EntityFlag.SWIMMING) && session.getCollisionManager().isPlayerInWater()) {
+            // As of 1.18.1, Java Edition cannot fly while in water, but it can fly while crawling
+            // If this isn't present, swimming on a 1.13.2 server and then attempting to fly will put you into a flying/swimming state that is invalid on JE
+            session.sendAdventureSettings();
+            return;
+        }
+
+        session.setFlying(isFlying);
+        ServerboundPlayerAbilitiesPacket abilitiesPacket = new ServerboundPlayerAbilitiesPacket(isFlying);
+        session.sendDownstreamPacket(abilitiesPacket);
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java
index 8641a35ff..121bfd065 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockSetLocalPlayerAsInitializedTranslator.java
@@ -26,7 +26,7 @@
 package org.geysermc.geyser.translator.protocol.bedrock;
 
 import com.nukkitx.protocol.bedrock.packet.SetLocalPlayerAsInitializedPacket;
-import org.geysermc.geyser.session.auth.AuthType;
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
@@ -41,10 +41,10 @@ public class BedrockSetLocalPlayerAsInitializedTranslator extends PacketTranslat
             if (!session.getUpstream().isInitialized()) {
                 session.getUpstream().setInitialized(true);
 
-                if (session.getRemoteAuthType() == AuthType.ONLINE) {
+                if (session.remoteServer().authType() == AuthType.ONLINE) {
                     if (!session.isLoggedIn()) {
-                        if (session.getGeyser().getConfig().getSavedUserLogins().contains(session.name())) {
-                            if (session.getGeyser().refreshTokenFor(session.name()) == null) {
+                        if (session.getGeyser().getConfig().getSavedUserLogins().contains(session.bedrockUsername())) {
+                            if (session.getGeyser().refreshTokenFor(session.bedrockUsername()) == null) {
                                 LoginEncryptionUtils.buildAndShowConsentWindow(session);
                             } else {
                                 // If the refresh token is not null and we're here, then the refresh token expired
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockShowCreditsTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockShowCreditsTranslator.java
index ca6ac09dd..6fa271010 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockShowCreditsTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockShowCreditsTranslator.java
@@ -25,13 +25,12 @@
 
 package org.geysermc.geyser.translator.protocol.bedrock;
 
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.protocol.PacketTranslator;
-import org.geysermc.geyser.translator.protocol.Translator;
-
 import com.github.steveice10.mc.protocol.data.game.ClientCommand;
 import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundClientCommandPacket;
 import com.nukkitx.protocol.bedrock.packet.ShowCreditsPacket;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.translator.protocol.Translator;
 
 @Translator(packet = ShowCreditsPacket.class)
 public class BedrockShowCreditsTranslator extends PacketTranslator<ShowCreditsPacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/BedrockMovePlayerTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/BedrockMovePlayerTranslator.java
index 17d424b00..6078b7ebd 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/BedrockMovePlayerTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/BedrockMovePlayerTranslator.java
@@ -163,7 +163,7 @@ public class BedrockMovePlayerTranslator extends PacketTranslator<MovePlayerPack
             return false;
         }
         if (currentPosition.distanceSquared(newPosition) > 300) {
-            session.getGeyser().getLogger().debug(ChatColor.RED + session.name() + " moved too quickly." +
+            session.getGeyser().getLogger().debug(ChatColor.RED + session.bedrockUsername() + " moved too quickly." +
                     " current position: " + currentPosition + ", new position: " + newPosition);
 
             return false;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaBossEventTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaBossEventTranslator.java
index 1011eb739..30d6aa017 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaBossEventTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaBossEventTranslator.java
@@ -25,13 +25,12 @@
 
 package org.geysermc.geyser.translator.protocol.java;
 
+import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundBossEventPacket;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.cache.BossBar;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 
-import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundBossEventPacket;
-
 @Translator(packet = ClientboundBossEventPacket.class)
 public class JavaBossEventTranslator extends PacketTranslator<ClientboundBossEventPacket> {
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaChangeDifficultyTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaChangeDifficultyTranslator.java
index fdc2fa2fb..6504959dc 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaChangeDifficultyTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaChangeDifficultyTranslator.java
@@ -26,10 +26,10 @@
 package org.geysermc.geyser.translator.protocol.java;
 
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundChangeDifficultyPacket;
+import com.nukkitx.protocol.bedrock.packet.SetDifficultyPacket;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import com.nukkitx.protocol.bedrock.packet.SetDifficultyPacket;
 
 @Translator(packet = ClientboundChangeDifficultyPacket.class)
 public class JavaChangeDifficultyTranslator extends PacketTranslator<ClientboundChangeDifficultyPacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCommandsTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCommandsTranslator.java
index c9f192d3f..3fa43c788 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCommandsTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCommandsTranslator.java
@@ -45,13 +45,14 @@ import lombok.Getter;
 import lombok.ToString;
 import net.kyori.adventure.text.format.NamedTextColor;
 import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.command.CommandManager;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.protocol.PacketTranslator;
-import org.geysermc.geyser.translator.protocol.Translator;
+import org.geysermc.geyser.api.event.downstream.ServerDefineCommandsEvent;
+import org.geysermc.geyser.command.GeyserCommandManager;
 import org.geysermc.geyser.inventory.item.Enchantment;
 import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.registry.Registries;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.EntityUtils;
 
 import java.util.*;
@@ -115,7 +116,7 @@ public class JavaCommandsTranslator extends PacketTranslator<ClientboundCommands
             return;
         }
 
-        CommandManager manager = session.getGeyser().getCommandManager();
+        GeyserCommandManager manager = session.getGeyser().commandManager();
         CommandNode[] nodes = packet.getNodes();
         List<CommandData> commandData = new ArrayList<>();
         IntSet commandNodes = new IntOpenHashSet();
@@ -144,15 +145,20 @@ public class JavaCommandsTranslator extends PacketTranslator<ClientboundCommands
             CommandParamData[][] params = getParams(session, nodes[nodeIndex], nodes);
 
             // Insert the alias name into the command list
-            commands.computeIfAbsent(new BedrockCommandInfo(manager.getDescription(node.getName().toLowerCase(Locale.ROOT)), params),
+            commands.computeIfAbsent(new BedrockCommandInfo(node.getName().toLowerCase(Locale.ROOT), manager.description(node.getName().toLowerCase(Locale.ROOT)), params),
                     index -> new HashSet<>()).add(node.getName().toLowerCase());
         }
 
+        ServerDefineCommandsEvent event = new ServerDefineCommandsEvent(session, commands.keySet());
+        session.getGeyser().eventBus().fire(event);
+        if (event.isCancelled()) {
+            return;
+        }
+
         // The command flags, not sure what these do apart from break things
         List<CommandData.Flag> flags = Collections.emptyList();
 
         // Loop through all the found commands
-
         for (Map.Entry<BedrockCommandInfo, Set<String>> entry : commands.entrySet()) {
             String commandName = entry.getValue().iterator().next(); // We know this has a value
 
@@ -248,7 +254,7 @@ public class JavaCommandsTranslator extends PacketTranslator<ClientboundCommands
     /**
      * Stores the command description and parameter data for best optimizing the Bedrock commands packet.
      */
-    private record BedrockCommandInfo(String description, CommandParamData[][] paramData) {
+    private static record BedrockCommandInfo(String name, String description, CommandParamData[][] paramData) implements ServerDefineCommandsEvent.CommandInfo {
     }
 
     @Getter
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCustomPayloadTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCustomPayloadTranslator.java
index e25285114..aaedfa443 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCustomPayloadTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaCustomPayloadTranslator.java
@@ -38,8 +38,8 @@ import org.geysermc.cumulus.form.util.FormType;
 import org.geysermc.floodgate.pluginmessage.PluginMessageChannels;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 
@@ -52,7 +52,7 @@ public class JavaCustomPayloadTranslator extends PacketTranslator<ClientboundCus
     @Override
     public void translate(GeyserSession session, ClientboundCustomPayloadPacket packet) {
         // The only plugin messages it has to listen for are Floodgate plugin messages
-        if (session.getRemoteAuthType() != AuthType.FLOODGATE) {
+        if (session.remoteServer().authType() != AuthType.FLOODGATE) {
             return;
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaDisconnectTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaDisconnectTranslator.java
index 65fbd9381..96b0e3dbd 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaDisconnectTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaDisconnectTranslator.java
@@ -36,7 +36,7 @@ public class JavaDisconnectTranslator extends PacketTranslator<ClientboundDiscon
 
     @Override
     public void translate(GeyserSession session, ClientboundDisconnectPacket packet) {
-        session.disconnect(MessageTranslator.convertMessage(packet.getReason(), session.getLocale()));
+        session.disconnect(MessageTranslator.convertMessage(packet.getReason(), session.locale()));
     }
 
     @Override
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaGameProfileTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaGameProfileTranslator.java
index 199d29e30..7f8500ce4 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaGameProfileTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaGameProfileTranslator.java
@@ -27,12 +27,12 @@ package org.geysermc.geyser.translator.protocol.java;
 
 import com.github.steveice10.mc.auth.data.GameProfile;
 import com.github.steveice10.mc.protocol.packet.login.clientbound.ClientboundGameProfilePacket;
-import org.geysermc.geyser.session.auth.AuthType;
+import org.geysermc.geyser.api.network.AuthType;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.skin.SkinManager;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.skin.SkinManager;
 
 @Translator(packet = ClientboundGameProfilePacket.class)
 public class JavaGameProfileTranslator extends PacketTranslator<ClientboundGameProfilePacket> {
@@ -40,7 +40,7 @@ public class JavaGameProfileTranslator extends PacketTranslator<ClientboundGameP
     @Override
     public void translate(GeyserSession session, ClientboundGameProfilePacket packet) {
         PlayerEntity playerEntity = session.getPlayerEntity();
-        AuthType remoteAuthType = session.getRemoteAuthType();
+        AuthType remoteAuthType = session.remoteServer().authType();
 
         // Required, or else Floodgate players break with Spigot chunk caching
         GameProfile profile = packet.getProfile();
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginDisconnectTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginDisconnectTranslator.java
index 0315e1611..356fe645b 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginDisconnectTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaLoginDisconnectTranslator.java
@@ -30,7 +30,7 @@ import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.TextComponent;
 import net.kyori.adventure.text.TranslatableComponent;
 import org.geysermc.common.PlatformType;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
@@ -74,14 +74,14 @@ public class JavaLoginDisconnectTranslator extends PacketTranslator<ClientboundL
             }
         }
 
-        String serverDisconnectMessage = MessageTranslator.convertMessage(disconnectReason, session.getLocale());
+        String serverDisconnectMessage = MessageTranslator.convertMessage(disconnectReason, session.locale());
         String disconnectMessage;
         if (isOutdatedMessage) {
-            String locale = session.getLocale();
+            String locale = session.locale();
             PlatformType platform = session.getGeyser().getPlatformType();
             String outdatedType = (platform == PlatformType.BUNGEECORD || platform == PlatformType.VELOCITY) ?
                     "geyser.network.remote.outdated.proxy" : "geyser.network.remote.outdated.server";
-            disconnectMessage = GeyserLocale.getPlayerLocaleString(outdatedType, locale, MinecraftProtocol.getJavaVersions().get(0)) + '\n'
+            disconnectMessage = GeyserLocale.getPlayerLocaleString(outdatedType, locale, GameProtocol.getJavaVersions().get(0)) + '\n'
                     + GeyserLocale.getPlayerLocaleString("geyser.network.remote.original_disconnect_message", locale, serverDisconnectMessage);
         } else {
             disconnectMessage = serverDisconnectMessage;
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 ddbd66e05..6aa613b24 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
@@ -36,10 +36,10 @@ import com.nukkitx.protocol.bedrock.packet.GameRulesChangedPacket;
 import com.nukkitx.protocol.bedrock.packet.SetPlayerGameTypePacket;
 import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 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.JavaDimension;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.text.TextDecoration;
 import org.geysermc.geyser.translator.level.BiomeTranslator;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
@@ -135,7 +135,7 @@ public class JavaLoginTranslator extends PacketTranslator<ClientboundLoginPacket
         session.sendDownstreamPacket(new ServerboundCustomPayloadPacket("minecraft:brand", PluginMessageUtils.getGeyserBrandData()));
 
         // register the plugin messaging channels used in Floodgate
-        if (session.getRemoteAuthType() == AuthType.FLOODGATE) {
+        if (session.remoteServer().authType() == AuthType.FLOODGATE) {
             session.sendDownstreamPacket(new ServerboundCustomPayloadPacket("minecraft:register", PluginMessageChannels.getFloodgateRegisterData()));
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPingTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPingTranslator.java
index 6dcfda89a..faff85fec 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPingTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPingTranslator.java
@@ -25,8 +25,8 @@
 
 package org.geysermc.geyser.translator.protocol.java;
 
-import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundPongPacket;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundPingPacket;
+import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundPongPacket;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPlayerChatTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPlayerChatTranslator.java
index 74b27d417..143fa16a9 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPlayerChatTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaPlayerChatTranslator.java
@@ -72,9 +72,9 @@ public class JavaPlayerChatTranslator extends PacketTranslator<ClientboundPlayer
                 args.add(message);
             }
             withDecoration.args(args);
-            textPacket.setMessage(MessageTranslator.convertMessage(withDecoration.build(), session.getLocale()));
+            textPacket.setMessage(MessageTranslator.convertMessage(withDecoration.build(), session.locale()));
         } else {
-            textPacket.setMessage(MessageTranslator.convertMessage(message, session.getLocale()));
+            textPacket.setMessage(MessageTranslator.convertMessage(message, session.locale()));
         }
 
         session.sendUpstreamPacket(textPacket);
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaSystemChatTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaSystemChatTranslator.java
index b605dbbbc..d71055a87 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaSystemChatTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaSystemChatTranslator.java
@@ -44,7 +44,7 @@ public class JavaSystemChatTranslator extends PacketTranslator<ClientboundSystem
         textPacket.setType(packet.isOverlay() ? TextPacket.Type.TIP : TextPacket.Type.SYSTEM);
 
         textPacket.setNeedsTranslation(false);
-        textPacket.setMessage(MessageTranslator.convertMessage(packet.getContent(), session.getLocale()));
+        textPacket.setMessage(MessageTranslator.convertMessage(packet.getContent(), session.locale()));
 
         if (session.isSentSpawnPacket()) {
             session.sendUpstreamPacket(textPacket);
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateAdvancementsTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateAdvancementsTranslator.java
index a1d8da90c..94bac62bf 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateAdvancementsTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateAdvancementsTranslator.java
@@ -28,13 +28,13 @@ package org.geysermc.geyser.translator.protocol.java;
 import com.github.steveice10.mc.protocol.data.game.advancement.Advancement;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundUpdateAdvancementsPacket;
 import com.nukkitx.protocol.bedrock.packet.ToastRequestPacket;
+import org.geysermc.geyser.level.GeyserAdvancement;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.session.cache.AdvancementsCache;
+import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.translator.text.MessageTranslator;
-import org.geysermc.geyser.session.cache.AdvancementsCache;
-import org.geysermc.geyser.level.GeyserAdvancement;
-import org.geysermc.geyser.text.MinecraftLocale;
 
 import java.util.Locale;
 
@@ -82,8 +82,8 @@ public class JavaUpdateAdvancementsTranslator extends PacketTranslator<Clientbou
             if (advancement != null && advancement.getDisplayData() != null) {
                 if (advancement.getDisplayData().isShowToast() && session.getAdvancementsCache().isEarned(advancement)) {
                     String frameType = advancement.getDisplayData().getFrameType().toString().toLowerCase(Locale.ROOT);
-                    String frameTitle = advancement.getDisplayColor() + MinecraftLocale.getLocaleString("advancements.toast." + frameType, session.getLocale());
-                    String advancementName = MessageTranslator.convertMessage(advancement.getDisplayData().getTitle(), session.getLocale());
+                    String frameTitle = advancement.getDisplayColor() + MinecraftLocale.getLocaleString("advancements.toast." + frameType, session.locale());
+                    String advancementName = MessageTranslator.convertMessage(advancement.getDisplayData().getTitle(), session.locale());
 
                     ToastRequestPacket toastRequestPacket = new ToastRequestPacket();
                     toastRequestPacket.setTitle(frameTitle);
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java
index f1de83914..d11caf601 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java
@@ -34,23 +34,27 @@ import com.github.steveice10.mc.protocol.data.game.recipe.data.ShapelessRecipeDa
 import com.github.steveice10.mc.protocol.data.game.recipe.data.SmithingRecipeData;
 import com.github.steveice10.mc.protocol.data.game.recipe.data.StoneCuttingRecipeData;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundUpdateRecipesPacket;
-import com.nukkitx.nbt.NbtMap;
 import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.data.inventory.descriptor.DefaultDescriptor;
+import com.nukkitx.protocol.bedrock.data.inventory.descriptor.ItemDescriptorWithCount;
 import com.nukkitx.protocol.bedrock.packet.CraftingDataPacket;
-import it.unimi.dsi.fastutil.ints.*;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
 import lombok.AllArgsConstructor;
 import lombok.EqualsAndHashCode;
 import org.geysermc.geyser.inventory.recipe.GeyserRecipe;
 import org.geysermc.geyser.inventory.recipe.GeyserShapedRecipe;
 import org.geysermc.geyser.inventory.recipe.GeyserShapelessRecipe;
 import org.geysermc.geyser.inventory.recipe.GeyserStonecutterData;
-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.inventory.item.ItemTranslator;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ItemMapping;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.InventoryUtils;
 
 import java.util.*;
@@ -96,8 +100,8 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
                     }
                     // Strip NBT - tools won't appear in the recipe book otherwise
                     output = output.toBuilder().tag(null).build();
-                    ItemData[][] inputCombinations = combinations(session, shapelessRecipeData.getIngredients());
-                    for (ItemData[] inputs : inputCombinations) {
+                    ItemDescriptorWithCount[][] inputCombinations = combinations(session, shapelessRecipeData.getIngredients());
+                    for (ItemDescriptorWithCount[] inputs : inputCombinations) {
                         UUID uuid = UUID.randomUUID();
                         craftingDataPacket.getCraftingData().add(CraftingData.fromShapeless(uuid.toString(),
                                 Arrays.asList(inputs), Collections.singletonList(output), uuid, "crafting_table", 0, netId));
@@ -113,8 +117,8 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
                     }
                     // See above
                     output = output.toBuilder().tag(null).build();
-                    ItemData[][] inputCombinations = combinations(session, shapedRecipeData.getIngredients());
-                    for (ItemData[] inputs : inputCombinations) {
+                    ItemDescriptorWithCount[][] inputCombinations = combinations(session, shapedRecipeData.getIngredients());
+                    for (ItemDescriptorWithCount[] inputs : inputCombinations) {
                         UUID uuid = UUID.randomUUID();
                         craftingDataPacket.getCraftingData().add(CraftingData.fromShaped(uuid.toString(),
                                 shapedRecipeData.getWidth(), shapedRecipeData.getHeight(), Arrays.asList(inputs),
@@ -138,14 +142,14 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
                     SmithingRecipeData recipeData = (SmithingRecipeData) recipe.getData();
                     ItemData output = ItemTranslator.translateToBedrock(session, recipeData.getResult());
                     for (ItemStack base : recipeData.getBase().getOptions()) {
-                        ItemData bedrockBase = ItemTranslator.translateToBedrock(session, base);
+                        ItemDescriptorWithCount bedrockBase = ItemDescriptorWithCount.fromItem(ItemTranslator.translateToBedrock(session, base));
 
                         for (ItemStack addition : recipeData.getAddition().getOptions()) {
-                            ItemData bedrockAddition = ItemTranslator.translateToBedrock(session, addition);
+                            ItemDescriptorWithCount bedrockAddition = ItemDescriptorWithCount.fromItem(ItemTranslator.translateToBedrock(session, addition));
 
                             UUID uuid = UUID.randomUUID();
                             craftingDataPacket.getCraftingData().add(CraftingData.fromShapeless(uuid.toString(),
-                                    Arrays.asList(bedrockBase, bedrockAddition),
+                                    List.of(bedrockBase, bedrockAddition),
                                     Collections.singletonList(output), uuid, "smithing_table", 2, netId++));
                         }
                     }
@@ -175,6 +179,7 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
                 // As of 1.16.4, all stonecutter recipes have one ingredient option
                 ItemStack ingredient = stoneCuttingData.getIngredient().getOptions()[0];
                 ItemData input = ItemTranslator.translateToBedrock(session, ingredient);
+                ItemDescriptorWithCount descriptor = ItemDescriptorWithCount.fromItem(input);
                 ItemStack javaOutput = stoneCuttingData.getResult();
                 ItemData output = ItemTranslator.translateToBedrock(session, javaOutput);
                 if (input.equals(ItemData.AIR) || output.equals(ItemData.AIR)) {
@@ -185,7 +190,7 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
 
                 // We need to register stonecutting recipes so they show up on Bedrock
                 craftingDataPacket.getCraftingData().add(CraftingData.fromShapeless(uuid.toString(),
-                        Collections.singletonList(input), Collections.singletonList(output), uuid, "stonecutter", 0, netId));
+                        Collections.singletonList(descriptor), Collections.singletonList(output), uuid, "stonecutter", 0, netId));
 
                 // Save the recipe list for reference when crafting
                 // Add the net ID as the key and the button required + output for the value
@@ -206,19 +211,19 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
      *
      * @return the Java ingredient list as an array that Bedrock can understand
      */
-    private ItemData[][] combinations(GeyserSession session, Ingredient[] ingredients) {
-        Map<Set<ItemData>, IntSet> squashedOptions = new HashMap<>();
+    private ItemDescriptorWithCount[][] combinations(GeyserSession session, Ingredient[] ingredients) {
+        Map<Set<ItemDescriptorWithCount>, IntSet> squashedOptions = new HashMap<>();
         for (int i = 0; i < ingredients.length; i++) {
             if (ingredients[i].getOptions().length == 0) {
-                squashedOptions.computeIfAbsent(Collections.singleton(ItemData.AIR), k -> new IntOpenHashSet()).add(i);
+                squashedOptions.computeIfAbsent(Collections.singleton(ItemDescriptorWithCount.EMPTY), k -> new IntOpenHashSet()).add(i);
                 continue;
             }
             Ingredient ingredient = ingredients[i];
-            Map<GroupedItem, List<ItemData>> groupedByIds = Arrays.stream(ingredient.getOptions())
-                    .map(item -> ItemTranslator.translateToBedrock(session, item))
-                    .collect(Collectors.groupingBy(item -> new GroupedItem(item.getId(), item.getCount(), item.getTag())));
-            Set<ItemData> optionSet = new HashSet<>(groupedByIds.size());
-            for (Map.Entry<GroupedItem, List<ItemData>> entry : groupedByIds.entrySet()) {
+            Map<GroupedItem, List<ItemDescriptorWithCount>> groupedByIds = Arrays.stream(ingredient.getOptions())
+                    .map(item -> ItemDescriptorWithCount.fromItem(ItemTranslator.translateToBedrock(session, item)))
+                    .collect(Collectors.groupingBy(item -> new GroupedItem(((DefaultDescriptor) item.getDescriptor()).getItemId(), item.getCount())));
+            Set<ItemDescriptorWithCount> optionSet = new HashSet<>(groupedByIds.size());
+            for (Map.Entry<GroupedItem, List<ItemDescriptorWithCount>> entry : groupedByIds.entrySet()) {
                 if (entry.getValue().size() > 1) {
                     GroupedItem groupedItem = entry.getKey();
                     int idCount = 0;
@@ -231,42 +236,38 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
                     if (entry.getValue().size() < idCount) {
                         optionSet.addAll(entry.getValue());
                     } else {
-                        optionSet.add(ItemData.builder()
-                                .id(groupedItem.id)
-                                .damage(Short.MAX_VALUE)
-                                .count(groupedItem.count)
-                                .tag(groupedItem.tag).build());
+                        optionSet.add(new ItemDescriptorWithCount(new DefaultDescriptor(groupedItem.id, Short.MAX_VALUE), groupedItem.count));
                     }
                 } else {
-                    ItemData item = entry.getValue().get(0);
+                    ItemDescriptorWithCount item = entry.getValue().get(0);
                     optionSet.add(item);
                 }
             }
             squashedOptions.computeIfAbsent(optionSet, k -> new IntOpenHashSet()).add(i);
         }
         int totalCombinations = 1;
-        for (Set<ItemData> optionSet : squashedOptions.keySet()) {
+        for (Set<ItemDescriptorWithCount> optionSet : squashedOptions.keySet()) {
             totalCombinations *= optionSet.size();
         }
         if (totalCombinations > 500) {
-            ItemData[] translatedItems = new ItemData[ingredients.length];
+            ItemDescriptorWithCount[] translatedItems = new ItemDescriptorWithCount[ingredients.length];
             for (int i = 0; i < ingredients.length; i++) {
                 if (ingredients[i].getOptions().length > 0) {
-                    translatedItems[i] = ItemTranslator.translateToBedrock(session, ingredients[i].getOptions()[0]);
+                    translatedItems[i] = ItemDescriptorWithCount.fromItem(ItemTranslator.translateToBedrock(session, ingredients[i].getOptions()[0]));
                 } else {
-                    translatedItems[i] = ItemData.AIR;
+                    translatedItems[i] = ItemDescriptorWithCount.EMPTY;
                 }
             }
-            return new ItemData[][]{translatedItems};
+            return new ItemDescriptorWithCount[][]{translatedItems};
         }
-        List<Set<ItemData>> sortedSets = new ArrayList<>(squashedOptions.keySet());
+        List<Set<ItemDescriptorWithCount>> sortedSets = new ArrayList<>(squashedOptions.keySet());
         sortedSets.sort(Comparator.comparing(Set::size, Comparator.reverseOrder()));
-        ItemData[][] combinations = new ItemData[totalCombinations][ingredients.length];
+        ItemDescriptorWithCount[][] combinations = new ItemDescriptorWithCount[totalCombinations][ingredients.length];
         int x = 1;
-        for (Set<ItemData> set : sortedSets) {
+        for (Set<ItemDescriptorWithCount> set : sortedSets) {
             IntSet slotSet = squashedOptions.get(set);
             int i = 0;
-            for (ItemData item : set) {
+            for (ItemDescriptorWithCount item : set) {
                 for (int j = 0; j < totalCombinations / set.size(); j++) {
                     final int comboIndex = (i * x) + (j % x) + ((j / x) * set.size() * x);
                     for (int slot : slotSet) {
@@ -285,6 +286,5 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator<ClientboundUpd
     private static class GroupedItem {
         int id;
         int count;
-        NbtMap tag;
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEntityMotionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEntityMotionTranslator.java
index bf947382e..c627d5e22 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEntityMotionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEntityMotionTranslator.java
@@ -25,6 +25,9 @@
 
 package org.geysermc.geyser.translator.protocol.java.entity;
 
+import com.github.steveice10.mc.protocol.packet.ingame.clientbound.entity.ClientboundSetEntityMotionPacket;
+import com.nukkitx.math.vector.Vector3f;
+import com.nukkitx.protocol.bedrock.packet.SetEntityMotionPacket;
 import org.geysermc.geyser.entity.type.Entity;
 import org.geysermc.geyser.entity.type.ItemEntity;
 import org.geysermc.geyser.entity.type.living.animal.horse.AbstractHorseEntity;
@@ -32,10 +35,6 @@ import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 
-import com.github.steveice10.mc.protocol.packet.ingame.clientbound.entity.ClientboundSetEntityMotionPacket;
-import com.nukkitx.math.vector.Vector3f;
-import com.nukkitx.protocol.bedrock.packet.SetEntityMotionPacket;
-
 @Translator(packet = ClientboundSetEntityMotionPacket.class)
 public class JavaSetEntityMotionTranslator extends PacketTranslator<ClientboundSetEntityMotionPacket> {
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerCombatKillTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerCombatKillTranslator.java
index 0f1fd4d1b..89be26e4a 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerCombatKillTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerCombatKillTranslator.java
@@ -28,7 +28,7 @@ package org.geysermc.geyser.translator.protocol.java.entity.player;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.entity.player.ClientboundPlayerCombatKillPacket;
 import com.nukkitx.protocol.bedrock.packet.DeathInfoPacket;
 import net.kyori.adventure.text.Component;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
@@ -39,11 +39,11 @@ public class JavaPlayerCombatKillTranslator extends PacketTranslator<Clientbound
 
     @Override
     public void translate(GeyserSession session, ClientboundPlayerCombatKillPacket packet) {
-        if (packet.getPlayerId() == session.getPlayerEntity().getEntityId() && MinecraftProtocol.supports1_19_10(session)) {
+        if (packet.getPlayerId() == session.getPlayerEntity().getEntityId() && GameProtocol.supports1_19_10(session)) {
             Component deathMessage = packet.getMessage();
             // TODO - could inject score in, but as of 1.19.10 newlines don't center and start at the left of the first text
             DeathInfoPacket deathInfoPacket = new DeathInfoPacket();
-            deathInfoPacket.setCauseAttackName(MessageTranslator.convertMessage(deathMessage, session.getLocale()));
+            deathInfoPacket.setCauseAttackName(MessageTranslator.convertMessage(deathMessage, session.locale()));
             session.sendUpstreamPacket(deathInfoPacket);
         }
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoTranslator.java
index 993da7746..1cefb9731 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoTranslator.java
@@ -34,9 +34,9 @@ import com.nukkitx.protocol.bedrock.packet.PlayerListPacket;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.skin.SkinManager;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.skin.SkinManager;
 
 @Translator(packet = ClientboundPlayerInfoPacket.class)
 public class JavaPlayerInfoTranslator extends PacketTranslator<ClientboundPlayerInfoPacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/spawn/JavaAddPlayerTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/spawn/JavaAddPlayerTranslator.java
index c54b75f4f..c4b5aae2d 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/spawn/JavaAddPlayerTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/spawn/JavaAddPlayerTranslator.java
@@ -30,10 +30,10 @@ import com.nukkitx.math.vector.Vector3f;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.skin.SkinManager;
+import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.text.GeyserLocale;
-import org.geysermc.geyser.skin.SkinManager;
 
 @Translator(packet = ClientboundAddPlayerPacket.class)
 public class JavaAddPlayerTranslator extends PacketTranslator<ClientboundAddPlayerPacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java
index f905cd661..619825338 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java
@@ -30,9 +30,9 @@ import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.util.InventoryUtils;
 
 @Translator(packet = ClientboundContainerSetContentPacket.class)
@@ -48,7 +48,7 @@ public class JavaContainerSetContentTranslator extends PacketTranslator<Clientbo
         for (int i = 0; i < packet.getItems().length; i++) {
             if (i > inventorySize) {
                 GeyserImpl geyser = session.getGeyser();
-                geyser.getLogger().warning("ClientboundContainerSetContentPacket sent to " + session.name()
+                geyser.getLogger().warning("ClientboundContainerSetContentPacket sent to " + session.bedrockUsername()
                         + " that exceeds inventory size!");
                 if (geyser.getConfig().isDebugMode()) {
                     geyser.getLogger().debug(packet);
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetDataTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetDataTranslator.java
index 30c2abe25..923b10a26 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetDataTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetDataTranslator.java
@@ -28,9 +28,9 @@ package org.geysermc.geyser.translator.protocol.java.inventory;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.inventory.ClientboundContainerSetDataPacket;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.util.InventoryUtils;
 
 @Translator(packet = ClientboundContainerSetDataPacket.class)
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java
index aef8cf8b2..869f0cede 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java
@@ -31,6 +31,7 @@ import com.github.steveice10.mc.protocol.packet.ingame.clientbound.inventory.Cli
 import com.nukkitx.protocol.bedrock.data.inventory.ContainerId;
 import com.nukkitx.protocol.bedrock.data.inventory.CraftingData;
 import com.nukkitx.protocol.bedrock.data.inventory.ItemData;
+import com.nukkitx.protocol.bedrock.data.inventory.descriptor.ItemDescriptorWithCount;
 import com.nukkitx.protocol.bedrock.packet.CraftingDataPacket;
 import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket;
 import org.geysermc.geyser.GeyserImpl;
@@ -76,7 +77,7 @@ public class JavaContainerSetSlotTranslator extends PacketTranslator<Clientbound
             int slot = packet.getSlot();
             if (slot >= inventory.getSize()) {
                 GeyserImpl geyser = session.getGeyser();
-                geyser.getLogger().warning("ClientboundContainerSetSlotPacket sent to " + session.name()
+                geyser.getLogger().warning("ClientboundContainerSetSlotPacket sent to " + session.bedrockUsername()
                         + " that exceeds inventory size!");
                 if (geyser.getConfig().isDebugMode()) {
                     geyser.getLogger().debug(packet);
@@ -186,7 +187,7 @@ public class JavaContainerSetSlotTranslator extends PacketTranslator<Clientbound
                     uuid.toString(),
                     width,
                     height,
-                    Arrays.asList(ingredients),
+                    Arrays.stream(ingredients).map(ItemDescriptorWithCount::fromItem).toList(),
                     Collections.singletonList(ItemTranslator.translateToBedrock(session, item)),
                     uuid,
                     "crafting_table",
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaHorseScreenOpenTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaHorseScreenOpenTranslator.java
index 727a17785..46b138076 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaHorseScreenOpenTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaHorseScreenOpenTranslator.java
@@ -36,12 +36,12 @@ import org.geysermc.geyser.entity.type.living.animal.horse.ChestedHorseEntity;
 import org.geysermc.geyser.entity.type.living.animal.horse.LlamaEntity;
 import org.geysermc.geyser.inventory.Container;
 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.inventory.InventoryTranslator;
 import org.geysermc.geyser.translator.inventory.horse.DonkeyInventoryTranslator;
 import org.geysermc.geyser.translator.inventory.horse.HorseInventoryTranslator;
 import org.geysermc.geyser.translator.inventory.horse.LlamaInventoryTranslator;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.InventoryUtils;
 
 import java.util.ArrayList;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaMerchantOffersTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaMerchantOffersTranslator.java
index 91f1a2e23..2ebe48a83 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaMerchantOffersTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaMerchantOffersTranslator.java
@@ -170,11 +170,12 @@ public class JavaMerchantOffersTranslator extends PacketTranslator<ClientboundMe
 
     private static NbtMap getItemTag(GeyserSession session, ItemStack stack, ItemMapping mapping, int count) {
         ItemData itemData = ItemTranslator.translateToBedrock(session, stack);
+        String customIdentifier = session.getItemMappings().getCustomIdMappings().get(itemData.getId());
 
         NbtMapBuilder builder = NbtMap.builder();
         builder.putByte("Count", (byte) count);
         builder.putShort("Damage", (short) itemData.getDamage());
-        builder.putString("Name", mapping.getBedrockIdentifier());
+        builder.putString("Name", customIdentifier != null ? customIdentifier : mapping.getBedrockIdentifier());
         if (itemData.getTag() != null) {
             NbtMap tag = itemData.getTag().toBuilder().build();
             builder.put("tag", tag);
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaOpenScreenTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaOpenScreenTranslator.java
index d4c3b43b7..8730d5ac1 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaOpenScreenTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaOpenScreenTranslator.java
@@ -56,7 +56,7 @@ public class JavaOpenScreenTranslator extends PacketTranslator<ClientboundOpenSc
             return;
         }
 
-        String name = MessageTranslator.convertMessage(packet.getTitle(), session.getLocale());
+        String name = MessageTranslator.convertMessage(packet.getTitle(), session.locale());
 
         Inventory newInventory = newTranslator.createInventory(name, packet.getContainerId(), packet.getType(), session.getPlayerInventory());
         if (openInventory != null) {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaGameEventTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaGameEventTranslator.java
index a4bdf162e..05e14c41b 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaGameEventTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaGameEventTranslator.java
@@ -31,8 +31,8 @@ import com.github.steveice10.mc.protocol.data.game.level.notify.EnterCreditsValu
 import com.github.steveice10.mc.protocol.data.game.level.notify.RainStrengthValue;
 import com.github.steveice10.mc.protocol.data.game.level.notify.RespawnScreenValue;
 import com.github.steveice10.mc.protocol.data.game.level.notify.ThunderStrengthValue;
-import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundClientCommandPacket;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.level.ClientboundGameEventPacket;
+import com.github.steveice10.mc.protocol.packet.ingame.serverbound.ServerboundClientCommandPacket;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.GameRuleData;
 import com.nukkitx.protocol.bedrock.data.LevelEventType;
@@ -40,10 +40,10 @@ import com.nukkitx.protocol.bedrock.data.entity.EntityEventType;
 import com.nukkitx.protocol.bedrock.packet.*;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
 import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.text.MinecraftLocale;
+import org.geysermc.geyser.translator.inventory.PlayerInventoryTranslator;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.translator.inventory.PlayerInventoryTranslator;
-import org.geysermc.geyser.text.MinecraftLocale;
 
 @Translator(packet = ClientboundGameEventPacket.class)
 public class JavaGameEventTranslator extends PacketTranslator<ClientboundGameEventPacket> {
@@ -153,7 +153,7 @@ public class JavaGameEventTranslator extends PacketTranslator<ClientboundGameEve
             case INVALID_BED:
                 // Not sent as a proper message? Odd.
                 session.sendMessage(MinecraftLocale.getLocaleString("block.minecraft.spawn.not_valid",
-                        session.getLocale()));
+                        session.locale()));
                 break;
             case ARROW_HIT_PLAYER:
                 PlaySoundPacket arrowSoundPacket = new PlaySoundPacket();
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelEventTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelEventTranslator.java
index 8fcfa381f..b9fccc80a 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelEventTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelEventTranslator.java
@@ -77,7 +77,7 @@ public class JavaLevelEventTranslator extends PacketTranslator<ClientboundLevelE
                 textPacket.setSourceName(null);
                 textPacket.setMessage("record.nowPlaying");
                 String recordString = "%item." + soundEvent.name().toLowerCase(Locale.ROOT) + ".desc";
-                textPacket.setParameters(Collections.singletonList(MinecraftLocale.getLocaleString(recordString, session.getLocale())));
+                textPacket.setParameters(Collections.singletonList(MinecraftLocale.getLocaleString(recordString, session.locale())));
                 session.sendUpstreamPacket(textPacket);
             }
             return;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelParticlesTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelParticlesTranslator.java
index a413421a3..c77caadee 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelParticlesTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelParticlesTranslator.java
@@ -39,12 +39,12 @@ import com.nukkitx.protocol.bedrock.packet.LevelEventGenericPacket;
 import com.nukkitx.protocol.bedrock.packet.LevelEventPacket;
 import com.nukkitx.protocol.bedrock.packet.SpawnParticleEffectPacket;
 import org.geysermc.geyser.entity.type.Entity;
-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.inventory.item.ItemTranslator;
 import org.geysermc.geyser.registry.Registries;
 import org.geysermc.geyser.registry.type.ParticleMapping;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
+import org.geysermc.geyser.translator.protocol.PacketTranslator;
+import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.DimensionUtils;
 
 import java.util.Optional;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaMapItemDataTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaMapItemDataTranslator.java
index 495455958..4685cf115 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaMapItemDataTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaMapItemDataTranslator.java
@@ -31,12 +31,12 @@ import com.github.steveice10.mc.protocol.packet.ingame.clientbound.level.Clientb
 import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.protocol.bedrock.data.MapDecoration;
 import com.nukkitx.protocol.bedrock.data.MapTrackedObject;
+import org.geysermc.geyser.level.BedrockMapIcon;
+import org.geysermc.geyser.level.MapColor;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
-import org.geysermc.geyser.level.BedrockMapIcon;
 import org.geysermc.geyser.util.DimensionUtils;
-import org.geysermc.geyser.level.MapColor;
 
 @Translator(packet = ClientboundMapItemDataPacket.class)
 public class JavaMapItemDataTranslator extends PacketTranslator<ClientboundMapItemDataPacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaSetChunkCacheCenterTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaSetChunkCacheCenterTranslator.java
index 7b7223316..13e8cd62b 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaSetChunkCacheCenterTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaSetChunkCacheCenterTranslator.java
@@ -26,11 +26,11 @@
 package org.geysermc.geyser.translator.protocol.java.level;
 
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.level.ClientboundSetChunkCacheCenterPacket;
+import com.nukkitx.math.vector.Vector3i;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.ChunkUtils;
-import com.nukkitx.math.vector.Vector3i;
 
 @Translator(packet = ClientboundSetChunkCacheCenterPacket.class)
 public class JavaSetChunkCacheCenterTranslator extends PacketTranslator<ClientboundSetChunkCacheCenterPacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetDisplayObjectiveTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetDisplayObjectiveTranslator.java
index b34e54db6..74f063e44 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetDisplayObjectiveTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/scoreboard/JavaSetDisplayObjectiveTranslator.java
@@ -26,12 +26,12 @@
 package org.geysermc.geyser.translator.protocol.java.scoreboard;
 
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetDisplayObjectivePacket;
+import org.geysermc.geyser.scoreboard.Scoreboard;
+import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
 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.scoreboard.Scoreboard;
-import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
 
 @Translator(packet = ClientboundSetDisplayObjectivePacket.class)
 public class JavaSetDisplayObjectiveTranslator extends PacketTranslator<ClientboundSetDisplayObjectivePacket> {
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 33da27a88..3b009a2a5 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
@@ -31,15 +31,15 @@ import com.github.steveice10.mc.protocol.packet.ingame.clientbound.scoreboard.Cl
 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.geyser.scoreboard.Objective;
-import org.geysermc.geyser.scoreboard.Scoreboard;
-import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
-import org.geysermc.geyser.scoreboard.UpdateType;
 
 @Translator(packet = ClientboundSetObjectivePacket.class)
 public class JavaSetObjectiveTranslator extends PacketTranslator<ClientboundSetObjectivePacket> {
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 40129701d..f942b6f09 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
@@ -31,14 +31,14 @@ import com.github.steveice10.mc.protocol.data.game.scoreboard.TeamColor;
 import com.github.steveice10.mc.protocol.packet.ingame.clientbound.scoreboard.ClientboundSetPlayerTeamPacket;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
-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 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;
@@ -67,8 +67,8 @@ public class JavaSetPlayerTeamTranslator extends PacketTranslator<ClientboundSet
                         .setName(MessageTranslator.convertMessage(packet.getDisplayName()))
                         .setColor(packet.getColor())
                         .setNameTagVisibility(packet.getNameTagVisibility())
-                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.getLocale()))
-                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.getLocale()));
+                        .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()))
@@ -98,8 +98,8 @@ public class JavaSetPlayerTeamTranslator extends PacketTranslator<ClientboundSet
                 team.setName(MessageTranslator.convertMessage(packet.getDisplayName()))
                         .setColor(packet.getColor())
                         .setNameTagVisibility(packet.getNameTagVisibility())
-                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.getLocale()))
-                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.getLocale()))
+                        .setPrefix(MessageTranslator.convertMessage(packet.getPrefix(), session.locale()))
+                        .setSuffix(MessageTranslator.convertMessage(packet.getSuffix(), session.locale()))
                         .setUpdateType(UpdateType.UPDATE);
 
                 if (oldVisibility != team.getNameTagVisibility()
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 41e9a38a2..41b978a86 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
@@ -33,14 +33,14 @@ import com.nukkitx.protocol.bedrock.packet.SetEntityDataPacket;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
 import org.geysermc.geyser.entity.type.player.PlayerEntity;
-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.scoreboard.Objective;
 import org.geysermc.geyser.scoreboard.Scoreboard;
 import org.geysermc.geyser.scoreboard.ScoreboardUpdater;
+import org.geysermc.geyser.session.GeyserSession;
+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;
 
 @Translator(packet = ClientboundSetScorePacket.class)
 public class JavaSetScoreTranslator extends PacketTranslator<ClientboundSetScorePacket> {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetActionBarTextTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetActionBarTextTranslator.java
index c6473e375..a534ec372 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetActionBarTextTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetActionBarTextTranslator.java
@@ -41,7 +41,7 @@ public class JavaSetActionBarTextTranslator extends PacketTranslator<Clientbound
         if (packet.getText() == null) { //TODO 1.17 can this happen?
             text = " ";
         } else {
-            text = MessageTranslator.convertMessage(packet.getText(), session.getLocale());
+            text = MessageTranslator.convertMessage(packet.getText(), session.locale());
         }
 
         SetTitlePacket titlePacket = new SetTitlePacket();
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetSubtitleTextTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetSubtitleTextTranslator.java
index 0689a62cd..1f4dec595 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetSubtitleTextTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetSubtitleTextTranslator.java
@@ -41,7 +41,7 @@ public class JavaSetSubtitleTextTranslator extends PacketTranslator<ClientboundS
         if (packet.getText() == null) { //TODO 1.17 can this happen?
             text = " ";
         } else {
-            text = MessageTranslator.convertMessage(packet.getText(), session.getLocale());
+            text = MessageTranslator.convertMessage(packet.getText(), session.locale());
         }
 
         SetTitlePacket titlePacket = new SetTitlePacket();
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetTitleTextTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetTitleTextTranslator.java
index 43c690727..22e5a51b5 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetTitleTextTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/title/JavaSetTitleTextTranslator.java
@@ -44,7 +44,7 @@ public class JavaSetTitleTextTranslator extends PacketTranslator<ClientboundSetT
         if (packet.getText() == null || Component.empty().equals(packet.getText())) { // This can happen, see https://github.com/KyoriPowered/adventure/issues/447
             text = " ";
         } else {
-            text = MessageTranslator.convertMessage(packet.getText(), session.getLocale());
+            text = MessageTranslator.convertMessage(packet.getText(), session.locale());
         }
 
         SetTitlePacket titlePacket = new SetTitlePacket();
diff --git a/core/src/main/java/org/geysermc/geyser/translator/sound/BlockSoundInteractionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/sound/BlockSoundInteractionTranslator.java
index 14f708153..ff21be3e8 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/sound/BlockSoundInteractionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/sound/BlockSoundInteractionTranslator.java
@@ -32,8 +32,8 @@ import com.github.steveice10.opennbt.tag.builtin.StringTag;
 import com.github.steveice10.opennbt.tag.builtin.Tag;
 import com.nukkitx.math.vector.Vector3f;
 import org.geysermc.geyser.inventory.GeyserItemStack;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.registry.Registries;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.util.BlockUtils;
 
 import java.util.Map;
diff --git a/core/src/main/java/org/geysermc/geyser/translator/sound/SoundInteractionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/sound/SoundInteractionTranslator.java
index 536d5e188..9cb6e40d2 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/sound/SoundInteractionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/sound/SoundInteractionTranslator.java
@@ -26,7 +26,6 @@
 package org.geysermc.geyser.translator.sound;
 
 import com.nukkitx.math.vector.Vector3f;
-
 import org.geysermc.geyser.session.GeyserSession;
 
 /**
diff --git a/core/src/main/java/org/geysermc/geyser/translator/sound/block/GrassPathInteractionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/sound/block/GrassPathInteractionTranslator.java
index 98f460dd7..aff4b5562 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/sound/block/GrassPathInteractionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/sound/block/GrassPathInteractionTranslator.java
@@ -28,10 +28,10 @@ package org.geysermc.geyser.translator.sound.block;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.SoundEvent;
 import com.nukkitx.protocol.bedrock.packet.LevelSoundEventPacket;
+import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.sound.BlockSoundInteractionTranslator;
 import org.geysermc.geyser.translator.sound.SoundTranslator;
-import org.geysermc.geyser.registry.BlockRegistries;
 
 @SoundTranslator(blocks = "grass_path", items = "shovel", ignoreSneakingWhileHolding = true)
 public class GrassPathInteractionTranslator implements BlockSoundInteractionTranslator {
diff --git a/core/src/main/java/org/geysermc/geyser/translator/sound/block/HoeInteractionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/sound/block/HoeInteractionTranslator.java
index 0e1aae95c..7d81e5fb0 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/sound/block/HoeInteractionTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/sound/block/HoeInteractionTranslator.java
@@ -28,10 +28,10 @@ package org.geysermc.geyser.translator.sound.block;
 import com.nukkitx.math.vector.Vector3f;
 import com.nukkitx.protocol.bedrock.data.SoundEvent;
 import com.nukkitx.protocol.bedrock.packet.LevelSoundEventPacket;
+import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.sound.BlockSoundInteractionTranslator;
 import org.geysermc.geyser.translator.sound.SoundTranslator;
-import org.geysermc.geyser.registry.BlockRegistries;
 
 @SoundTranslator(blocks = "farmland", items = "hoe", ignoreSneakingWhileHolding = true)
 public class HoeInteractionTranslator implements BlockSoundInteractionTranslator {
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 6f446f136..10b1bbc5a 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
@@ -247,7 +247,7 @@ public class MessageTranslator {
      */
     public static boolean isTooLong(String message, GeyserSession session) {
         if (message.length() > 256) {
-            session.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.chat.too_long", session.getLocale(), message.length()));
+            session.sendMessage(GeyserLocale.getPlayerLocaleString("geyser.chat.too_long", session.locale(), message.length()));
             return true;
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/util/ItemUtils.java b/core/src/main/java/org/geysermc/geyser/util/ItemUtils.java
index 37c4609fe..92967a10b 100644
--- a/core/src/main/java/org/geysermc/geyser/util/ItemUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/ItemUtils.java
@@ -25,7 +25,10 @@
 
 package org.geysermc.geyser.util;
 
-import com.github.steveice10.opennbt.tag.builtin.*;
+import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
+import com.github.steveice10.opennbt.tag.builtin.ListTag;
+import com.github.steveice10.opennbt.tag.builtin.StringTag;
+import com.github.steveice10.opennbt.tag.builtin.Tag;
 import it.unimi.dsi.fastutil.ints.Int2IntMap;
 import org.geysermc.geyser.session.GeyserSession;
 
diff --git a/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java b/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java
index 372d40258..8d832f8fa 100644
--- a/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/LoginEncryptionUtils.java
@@ -230,7 +230,7 @@ public class LoginEncryptionUtils {
 
         session.sendForm(
                 SimpleForm.builder()
-                        .translator(GeyserLocale::getPlayerLocaleString, session.getLocale())
+                        .translator(GeyserLocale::getPlayerLocaleString, session.locale())
                         .title("geyser.auth.login.form.notice.title")
                         .content("geyser.auth.login.form.notice.desc")
                         .optionalButton("geyser.auth.login.form.notice.btn_login.mojang", isPasswordAuthEnabled)
@@ -245,17 +245,11 @@ public class LoginEncryptionUtils {
                             }
 
                             if (response.clickedButtonId() == 1) {
-                                if (isPasswordAuthEnabled) {
-                                    session.setMicrosoftAccount(true);
-                                    buildAndShowMicrosoftAuthenticationWindow(session);
-                                } else {
-                                    // Just show the OAuth code
-                                    session.authenticateWithMicrosoftCode();
-                                }
+                                session.authenticateWithMicrosoftCode();
                                 return;
                             }
 
-                            session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.getLocale()));
+                            session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.locale()));
                         }));
     }
 
@@ -263,9 +257,11 @@ public class LoginEncryptionUtils {
      * Build a window that explains the user's credentials will be saved to the system.
      */
     public static void buildAndShowConsentWindow(GeyserSession session) {
+        String locale = session.locale();
+
         session.sendForm(
                 SimpleForm.builder()
-                        .translator(LoginEncryptionUtils::translate, session.getLocale())
+                        .translator(LoginEncryptionUtils::translate, locale)
                         .title("%gui.signIn")
                         .content("""
                                 geyser.auth.login.save_token.warning
@@ -278,9 +274,11 @@ public class LoginEncryptionUtils {
     }
 
     public static void buildAndShowTokenExpiredWindow(GeyserSession session) {
+        String locale = session.locale();
+
         session.sendForm(
                 SimpleForm.builder()
-                        .translator(LoginEncryptionUtils::translate, session.getLocale())
+                        .translator(LoginEncryptionUtils::translate, locale)
                         .title("geyser.auth.login.form.expired")
                         .content("""
                                 geyser.auth.login.save_token.expired
@@ -305,49 +303,22 @@ public class LoginEncryptionUtils {
     public static void buildAndShowLoginDetailsWindow(GeyserSession session) {
         session.sendForm(
                 CustomForm.builder()
-                        .translator(GeyserLocale::getPlayerLocaleString, session.getLocale())
+                        .translator(GeyserLocale::getPlayerLocaleString, session.locale())
                         .title("geyser.auth.login.form.details.title")
                         .label("geyser.auth.login.form.details.desc")
                         .input("geyser.auth.login.form.details.email", "account@geysermc.org", "")
                         .input("geyser.auth.login.form.details.pass", "123456", "")
                         .invalidResultHandler(() -> buildAndShowLoginDetailsWindow(session))
-                        .closedResultHandler(() -> {
-                            if (session.isMicrosoftAccount()) {
-                                buildAndShowMicrosoftAuthenticationWindow(session);
-                            } else {
-                                buildAndShowLoginWindow(session);
-                            }
-                        })
+                        .closedResultHandler(() -> buildAndShowLoginWindow(session))
                         .validResultHandler((response) -> session.authenticate(response.next(), response.next())));
     }
 
-    /**
-     * Prompts the user between either OAuth code login or manual password authentication
-     */
-    public static void buildAndShowMicrosoftAuthenticationWindow(GeyserSession session) {
-        session.sendForm(
-                SimpleForm.builder()
-                        .translator(GeyserLocale::getPlayerLocaleString, session.getLocale())
-                        .title("geyser.auth.login.form.notice.btn_login.microsoft")
-                        .button("geyser.auth.login.method.browser")
-                        .button("geyser.auth.login.method.password")
-                        .button("geyser.auth.login.form.notice.btn_disconnect")
-                        .closedOrInvalidResultHandler(() -> buildAndShowLoginWindow(session))
-                        .validResultHandler((response) -> {
-                            if (response.clickedButtonId() == 0) {
-                                session.authenticateWithMicrosoftCode();
-                            } else if (response.clickedButtonId() == 1) {
-                                buildAndShowLoginDetailsWindow(session);
-                            } else {
-                                session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.getLocale()));
-                            }
-                        }));
-    }
-
     /**
      * Shows the code that a user must input into their browser
      */
     public static void buildAndShowMicrosoftCodeWindow(GeyserSession session, MsaAuthenticationService.MsCodeResponse msCode) {
+        String locale = session.locale();
+
         StringBuilder message = new StringBuilder("%xbox.signin.website\n")
                 .append(ChatColor.AQUA)
                 .append("%xbox.signin.url")
@@ -359,7 +330,7 @@ public class LoginEncryptionUtils {
         if (timeout != 0) {
             message.append("\n\n")
                     .append(ChatColor.RESET)
-                    .append(GeyserLocale.getPlayerLocaleString("geyser.auth.login.timeout", session.getLocale(), String.valueOf(timeout)));
+                    .append(GeyserLocale.getPlayerLocaleString("geyser.auth.login.timeout", session.locale(), String.valueOf(timeout)));
         }
 
         session.sendForm(
@@ -368,10 +339,10 @@ public class LoginEncryptionUtils {
                         .content(message.toString())
                         .button1("%gui.done")
                         .button2("%menu.disconnect")
-                        .closedOrInvalidResultHandler(() -> buildAndShowMicrosoftAuthenticationWindow(session))
+                        .closedOrInvalidResultHandler(() -> buildAndShowLoginWindow(session))
                         .validResultHandler((response) -> {
                             if (response.clickedButtonId() == 1) {
-                                session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.getLocale()));
+                                session.disconnect(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.disconnect", locale));
                             }
                         })
         );
diff --git a/core/src/main/java/org/geysermc/geyser/util/NewsHandler.java b/core/src/main/java/org/geysermc/geyser/util/NewsHandler.java
index 31e3116d5..71e7c99c1 100644
--- a/core/src/main/java/org/geysermc/geyser/util/NewsHandler.java
+++ b/core/src/main/java/org/geysermc/geyser/util/NewsHandler.java
@@ -29,15 +29,15 @@ import com.google.gson.Gson;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonSyntaxException;
-import org.geysermc.geyser.Constants;
-import org.geysermc.geyser.GeyserImpl;
-import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.floodgate.news.NewsItem;
 import org.geysermc.floodgate.news.NewsItemAction;
 import org.geysermc.floodgate.news.data.AnnouncementData;
 import org.geysermc.floodgate.news.data.BuildSpecificData;
 import org.geysermc.floodgate.news.data.CheckAfterData;
+import org.geysermc.geyser.Constants;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.GeyserLogger;
+import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.text.ChatColor;
 
 import java.util.*;
diff --git a/core/src/main/java/org/geysermc/geyser/util/SettingsUtils.java b/core/src/main/java/org/geysermc/geyser/util/SettingsUtils.java
index 10c4b863c..483a4372f 100644
--- a/core/src/main/java/org/geysermc/geyser/util/SettingsUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/SettingsUtils.java
@@ -44,7 +44,7 @@ public class SettingsUtils {
      */
     public static CustomForm buildForm(GeyserSession session) {
         // Cache the language for cleaner access
-        String language = session.getLocale();
+        String language = session.locale();
 
         CustomForm.Builder builder = CustomForm.builder()
                 .translator(SettingsUtils::translateEntry, language)
diff --git a/core/src/main/java/org/geysermc/geyser/util/StatisticsUtils.java b/core/src/main/java/org/geysermc/geyser/util/StatisticsUtils.java
index 149656fd9..f01670106 100644
--- a/core/src/main/java/org/geysermc/geyser/util/StatisticsUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/StatisticsUtils.java
@@ -52,7 +52,7 @@ public class StatisticsUtils {
      */
     public static void buildAndSendStatisticsMenu(GeyserSession session) {
         // Cache the language for cleaner access
-        String language = session.getLocale();
+        String language = session.locale();
 
         session.sendForm(
                 SimpleForm.builder()
diff --git a/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java b/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java
index b1f97989f..049d78619 100644
--- a/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java
@@ -34,8 +34,8 @@ import net.kyori.adventure.text.format.TextDecoration;
 import org.geysermc.geyser.Constants;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.command.CommandSender;
-import org.geysermc.geyser.network.MinecraftProtocol;
+import org.geysermc.geyser.command.GeyserCommandSource;
+import org.geysermc.geyser.network.GameProtocol;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.util.concurrent.CompletableFuture;
@@ -54,13 +54,13 @@ public final class VersionCheckUtils {
         }
     }
 
-    public static void checkForGeyserUpdate(Supplier<CommandSender> recipient) {
+    public static void checkForGeyserUpdate(Supplier<GeyserCommandSource> recipient) {
         CompletableFuture.runAsync(() -> {
             try {
                 JsonNode json = WebUtils.getJson("https://api.geysermc.org/v2/versions/geyser");
                 JsonNode bedrock = json.get("bedrock").get("protocol");
                 int protocolVersion = bedrock.get("id").asInt();
-                if (MinecraftProtocol.getBedrockCodec(protocolVersion) != null) {
+                if (GameProtocol.getBedrockCodec(protocolVersion) != null) {
                     // We support the latest version! No need to print a message.
                     return;
                 }
@@ -68,11 +68,11 @@ public final class VersionCheckUtils {
                 final String newBedrockVersion = bedrock.get("name").asText();
 
                 // Delayed for two reasons: save unnecessary processing, and wait to load locale if this is on join.
-                CommandSender sender = recipient.get();
+                GeyserCommandSource sender = recipient.get();
 
                 // Overarching component is green - geyser.version.new component cannot be green or else the link blue is overshadowed
                 Component message = Component.text().color(NamedTextColor.GREEN)
-                        .append(Component.text(GeyserLocale.getPlayerLocaleString("geyser.version.new", sender.getLocale(), newBedrockVersion))
+                        .append(Component.text(GeyserLocale.getPlayerLocaleString("geyser.version.new", sender.locale(), newBedrockVersion))
                                 .replaceText(TextReplacementConfig.builder()
                                         .match("\\{1\\}") // Replace "Download here: {1}" so we can use fancy text component yesyes
                                         .replacement(Component.text()
diff --git a/core/src/main/java/org/geysermc/geyser/util/collection/LecternHasBookMap.java b/core/src/main/java/org/geysermc/geyser/util/collection/LecternHasBookMap.java
index 73cb68df1..913ea44d5 100644
--- a/core/src/main/java/org/geysermc/geyser/util/collection/LecternHasBookMap.java
+++ b/core/src/main/java/org/geysermc/geyser/util/collection/LecternHasBookMap.java
@@ -27,9 +27,9 @@ package org.geysermc.geyser.util.collection;
 
 import com.nukkitx.math.vector.Vector3i;
 import com.nukkitx.nbt.NbtMap;
+import org.geysermc.geyser.level.WorldManager;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.LecternInventoryTranslator;
-import org.geysermc.geyser.level.WorldManager;
 import org.geysermc.geyser.util.BlockEntityUtils;
 
 /**
diff --git a/core/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/core/src/main/resources/META-INF/services/javax.annotation.processing.Processor
deleted file mode 100644
index 463d1efad..000000000
--- a/core/src/main/resources/META-INF/services/javax.annotation.processing.Processor
+++ /dev/null
@@ -1,5 +0,0 @@
-org.geysermc.processor.BlockEntityProcessor
-org.geysermc.processor.CollisionRemapperProcessor
-org.geysermc.processor.ItemRemapperProcessor
-org.geysermc.processor.PacketTranslatorProcessor
-org.geysermc.processor.SoundHandlerProcessor
\ No newline at end of file
diff --git a/core/src/main/resources/git.properties b/core/src/main/resources/git.properties
new file mode 100644
index 000000000..f14e55623
--- /dev/null
+++ b/core/src/main/resources/git.properties
@@ -0,0 +1,7 @@
+git.branch=${branch}
+git.build.number=${buildNumber}
+git.build.version=${projectVersion}
+git.commit.id=${commit}
+git.commit.id.abbrev=${commitAbbrev}
+git.commit.message.full=${commitMessage}
+git.remote.origin.url=${repository}
diff --git a/core/src/main/resources/languages b/core/src/main/resources/languages
index ad92d550b..51d6f5ba7 160000
--- a/core/src/main/resources/languages
+++ b/core/src/main/resources/languages
@@ -1 +1 @@
-Subproject commit ad92d550bab49bc46f17db6aa0042035b66a1a10
+Subproject commit 51d6f5ba7d85bfda318879dad34481d9ef4d488d
diff --git a/core/src/main/resources/mappings b/core/src/main/resources/mappings
index f1c9c2fbb..2c68dab9d 160000
--- a/core/src/main/resources/mappings
+++ b/core/src/main/resources/mappings
@@ -1 +1 @@
-Subproject commit f1c9c2fbba0e102dc4f8c96dd9485f7ec9768174
+Subproject commit 2c68dab9d751f78b2f5b0298da5e338ad6bc07ca
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 000000000..8f6ac8e85
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,6 @@
+group=org.geysermc
+version=2.1.0-SNAPSHOT
+
+org.gradle.caching=true
+org.gradle.parallel=true
+org.gradle.vfs.watch=false
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..41d9927a4
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..41dfb8790
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 000000000..1b6c78733
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+# Collect all arguments for the java command;
+#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+#     shell script including quotes and variable substitutions, so put them in
+#     double quotes to make sure that they get re-expanded; and
+#   * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..107acd32c
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/pom.xml b/pom.xml
deleted file mode 100644
index c5b293c43..000000000
--- a/pom.xml
+++ /dev/null
@@ -1,82 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
-    <modelVersion>4.0.0</modelVersion>
-    <groupId>org.geysermc</groupId>
-    <artifactId>geyser-parent</artifactId>
-    <version>2.0.7-SNAPSHOT</version>
-    <packaging>pom</packaging>
-    <name>Geyser</name>
-    <description>Allows for players from Minecraft Bedrock Edition to join Minecraft Java Edition servers.</description>
-    <url>https://geysermc.org</url>
-
-    <properties>
-        <outputName>Geyser</outputName>
-        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
-        <maven.compiler.source>16</maven.compiler.source>
-        <maven.compiler.target>16</maven.compiler.target>
-        <sonar.organization>geysermc</sonar.organization>
-        <sonar.host.url>https://sonarcloud.io</sonar.host.url>
-    </properties>
-
-    <organization>
-        <name>GeyserMC</name>
-        <url>https://github.com/GeyserMC/Geyser/blob/master/pom.xml</url>
-    </organization>
-
-    <scm>
-        <connection>scm:git:https://github.com/GeyserMC/Geyser.git</connection>
-        <developerConnection>scm:git:git@github.com:GeyserMC/Geyser.git</developerConnection>
-        <url>https://github.com/GeyserMC/Geyser</url>
-    </scm>
-
-    <modules>
-        <module>ap</module>
-        <module>api</module>
-        <module>bootstrap</module>
-        <module>common</module>
-        <module>core</module>
-    </modules>
-
-    <repositories>
-        <repository>
-            <id>jitpack.io</id>
-            <url>https://jitpack.io</url>
-        </repository>
-        <repository>
-            <id>opencollab-release-repo</id>
-            <url>https://repo.opencollab.dev/maven-releases/</url>
-            <releases>
-                <enabled>true</enabled>
-            </releases>
-            <snapshots>
-                <enabled>false</enabled>
-            </snapshots>
-        </repository>
-        <repository>
-            <id>opencollab-snapshot-repo</id>
-            <url>https://repo.opencollab.dev/maven-snapshots/</url>
-            <releases>
-                <enabled>false</enabled>
-            </releases>
-            <snapshots>
-                <enabled>true</enabled>
-            </snapshots>
-        </repository>
-        <repository>
-            <id>sonatype</id>
-            <url>https://oss.sonatype.org/content/repositories/snapshots/</url>
-        </repository>
-    </repositories>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.projectlombok</groupId>
-            <artifactId>lombok</artifactId>
-            <version>1.18.20</version>
-            <scope>provided</scope>
-        </dependency>
-    </dependencies>
-</project>
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 000000000..f6d13ad0d
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,79 @@
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        // Floodgate, Cumulus etc.
+        maven("https://repo.opencollab.dev/main")
+
+        // Paper, Velocity
+        maven("https://repo.papermc.io/repository/maven-public")
+        // Spigot
+        maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots") {
+            mavenContent { snapshotsOnly() }
+        }
+
+        // BungeeCord
+        maven("https://oss.sonatype.org/content/repositories/snapshots") {
+            mavenContent { snapshotsOnly() }
+        }
+
+        // Minecraft
+        maven("https://libraries.minecraft.net") {
+            name = "minecraft"
+            mavenContent { releasesOnly() }
+        }
+
+        mavenLocal()
+        mavenCentral()
+
+        // ViaVersion
+        maven("https://repo.viaversion.com") {
+            name = "viaversion"
+        }
+
+        // Sponge
+        maven("https://repo.spongepowered.org/repository/maven-public/")
+
+        maven("https://jitpack.io") {
+            content { includeGroupByRegex("com\\.github\\..*") }
+        }
+
+        // For Adventure snapshots
+        maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
+    }
+}
+
+pluginManagement {
+    repositories {
+        gradlePluginPortal()
+    }
+    plugins {
+        id("net.kyori.blossom") version "1.2.0"
+        id("net.kyori.indra")
+        id("net.kyori.indra.git")
+    }
+    includeBuild("build-logic")
+}
+
+rootProject.name = "geyser-parent"
+
+include(":ap")
+include(":api")
+include(":geyser-api")
+include(":bungeecord")
+include(":spigot")
+include(":sponge")
+include(":standalone")
+include(":velocity")
+include(":common")
+include(":core")
+
+// Specify project dirs
+project(":api").projectDir = file("api/base")
+project(":geyser-api").projectDir = file("api/geyser")
+project(":bungeecord").projectDir = file("bootstrap/bungeecord")
+project(":spigot").projectDir = file("bootstrap/spigot")
+project(":sponge").projectDir = file("bootstrap/sponge")
+project(":standalone").projectDir = file("bootstrap/standalone")
+project(":velocity").projectDir = file("bootstrap/velocity")
\ No newline at end of file