diff --git a/api/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java b/api/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java
index 7094812a0f9..db37c921d51 100644
--- a/api/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java
+++ b/api/src/main/java/org/geysermc/geyser/api/connection/GeyserConnection.java
@@ -33,7 +33,10 @@
 import org.geysermc.geyser.api.command.CommandSource;
 import org.geysermc.geyser.api.entity.type.GeyserEntity;
 import org.geysermc.geyser.api.entity.type.player.GeyserPlayerEntity;
+import org.geysermc.geyser.api.preference.Preference;
+import org.geysermc.geyser.api.preference.PreferenceKey;
 
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 
@@ -95,4 +98,12 @@ public interface GeyserConnection extends Connection, CommandSource {
      */
     @NonNull
     Set<String> fogEffects();
+
+    <T> void storePreference(@NonNull PreferenceKey<T> key, @NonNull Preference<T> preference);
+
+    @NonNull
+    <T> Preference<T> requirePreference(@NonNull PreferenceKey<T> key) throws IllegalArgumentException;
+
+    @NonNull
+    <T> Optional<Preference<T>> getPreference(@NonNull PreferenceKey<T> key);
 }
diff --git a/api/src/main/java/org/geysermc/geyser/api/preference/BooleanPreference.java b/api/src/main/java/org/geysermc/geyser/api/preference/BooleanPreference.java
new file mode 100644
index 00000000000..2e575fa271b
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/preference/BooleanPreference.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public abstract class BooleanPreference extends Preference<Boolean> {
+
+    public BooleanPreference(@NonNull Boolean initialValue) {
+        super(initialValue);
+    }
+
+    @Override
+    public void onFormResponse(@NonNull Object response) {
+        if (response instanceof Boolean booleanValue) {
+            update(booleanValue);
+        }
+        throw new IllegalArgumentException(response + " is not a boolean");
+    }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/preference/FloatPreference.java b/api/src/main/java/org/geysermc/geyser/api/preference/FloatPreference.java
new file mode 100644
index 00000000000..64bf22306a2
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/preference/FloatPreference.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public abstract class FloatPreference extends Preference<Float> {
+
+    public FloatPreference(@NonNull Float initialValue) {
+        super(initialValue);
+    }
+
+    @Override
+    public void onFormResponse(@NonNull Object response) throws IllegalArgumentException {
+        if (response instanceof Float floatValue) {
+            update(floatValue);
+        }
+        throw new IllegalArgumentException(response + " is not a float");
+    }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/preference/IntegerPreference.java b/api/src/main/java/org/geysermc/geyser/api/preference/IntegerPreference.java
new file mode 100644
index 00000000000..409b6e60316
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/preference/IntegerPreference.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public abstract class IntegerPreference extends Preference<Integer> {
+
+    public IntegerPreference(@NonNull Integer initialValue) {
+        super(initialValue);
+    }
+
+    @Override
+    public void onFormResponse(@NonNull Object response) throws IllegalArgumentException {
+        if (response instanceof Integer integerValue) {
+            update(integerValue);
+        }
+        throw new IllegalArgumentException(response + " is not an integer");
+    }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/preference/Preference.java b/api/src/main/java/org/geysermc/geyser/api/preference/Preference.java
new file mode 100644
index 00000000000..10bb33d927d
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/preference/Preference.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.cumulus.component.Component;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+
+import java.util.Objects;
+
+public abstract class Preference<T> {
+
+    @NonNull
+    private T value;
+
+    public Preference(@NonNull T initialValue) {
+        this.value = Objects.requireNonNull(initialValue, "initialValue");
+    }
+
+    @NonNull
+    public final T value() {
+        return value;
+    }
+
+    public final void update(@NonNull T newValue) {
+        this.value = Objects.requireNonNull(newValue, "newValue");
+    }
+
+    public abstract boolean isModifiable(GeyserConnection connection);
+
+    public abstract Component component(GeyserConnection connection);
+
+    public abstract void onFormResponse(@NonNull Object response) throws IllegalArgumentException;
+
+    public void onUpdate(GeyserConnection connection) {
+        // no-op by default
+    }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/preference/PreferenceKey.java b/api/src/main/java/org/geysermc/geyser/api/preference/PreferenceKey.java
new file mode 100644
index 00000000000..a0063c7f6ee
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/preference/PreferenceKey.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+public record PreferenceKey<T>(String key) {
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/preference/StringPreference.java b/api/src/main/java/org/geysermc/geyser/api/preference/StringPreference.java
new file mode 100644
index 00000000000..24f1d8e3eab
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/preference/StringPreference.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public abstract class StringPreference extends Preference<String> {
+
+    public StringPreference(@NonNull String initialValue) {
+        super(initialValue);
+    }
+
+    @Override
+    public void onFormResponse(@NonNull Object response) throws IllegalArgumentException {
+        if (response instanceof String stringValue) {
+            update(stringValue);
+        }
+        throw new IllegalArgumentException(response + " is not a string");
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/configuration/CooldownType.java b/core/src/main/java/org/geysermc/geyser/configuration/CooldownType.java
new file mode 100644
index 00000000000..123de4c1aed
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/configuration/CooldownType.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2019-2023 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.configuration;
+
+import com.fasterxml.jackson.core.JacksonException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.IOException;
+
+@Getter
+@AllArgsConstructor
+public enum CooldownType {
+    TITLE("options.attack.crosshair"),
+    ACTIONBAR("options.attack.hotbar"),
+    DISABLED("options.off");
+
+    public static final String OPTION_DESCRIPTION = "options.attackIndicator";
+    public static final CooldownType[] VALUES = values();
+
+    private final String translation;
+
+    /**
+     * Convert the CooldownType string (from config) to the enum, DISABLED on fail
+     *
+     * @param name CooldownType string
+     * @return The converted CooldownType
+     */
+    public static CooldownType getByName(String name) {
+        if (name.equalsIgnoreCase("true")) { // Backwards config compatibility
+            return CooldownType.TITLE;
+        }
+
+        for (CooldownType type : VALUES) {
+            if (type.name().equalsIgnoreCase(name)) {
+                return type;
+            }
+        }
+        return DISABLED;
+    }
+
+    public static class Deserializer extends JsonDeserializer<CooldownType> {
+        @Override
+        public CooldownType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
+            return CooldownType.getByName(p.getValueAsString());
+        }
+    }
+}
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 222af341b35..133fb870175 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
@@ -82,7 +82,7 @@ public interface GeyserConfiguration {
 
     boolean isAllowThirdPartyEars();
 
-    String getShowCooldown();
+    CooldownType getShowCooldown();
 
     boolean isShowCoordinates();
 
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 e096d58fa68..8c0eb692db9 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
@@ -99,8 +99,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
     @JsonProperty("allow-third-party-capes")
     private boolean allowThirdPartyCapes = true;
 
+    @JsonDeserialize(using = CooldownType.Deserializer.class)
     @JsonProperty("show-cooldown")
-    private String showCooldown = "title";
+    private CooldownType showCooldown = CooldownType.TITLE;
 
     @JsonProperty("show-coordinates")
     private boolean showCoordinates = true;
diff --git a/core/src/main/java/org/geysermc/geyser/preference/CooldownPreference.java b/core/src/main/java/org/geysermc/geyser/preference/CooldownPreference.java
new file mode 100644
index 00000000000..4087f15b36e
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/preference/CooldownPreference.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.cumulus.component.Component;
+import org.geysermc.cumulus.component.DropdownComponent;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+import org.geysermc.geyser.api.preference.Preference;
+import org.geysermc.geyser.api.preference.PreferenceKey;
+import org.geysermc.geyser.configuration.CooldownType;
+import org.geysermc.geyser.session.GeyserSession;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class CooldownPreference extends Preference<CooldownType> {
+
+    public static final PreferenceKey<CooldownType> KEY = new PreferenceKey<>("geyser:cooldown_type");
+
+    private static final List<String> OPTIONS = Arrays.stream(CooldownType.VALUES)
+        .map(CooldownType::getTranslation)
+        .toList();
+
+    public CooldownPreference(GeyserSession session) {
+        super(configSetting(session));
+    }
+
+    @Override
+    public boolean isModifiable(GeyserConnection connection) {
+        return configSetting((GeyserSession) connection) != CooldownType.DISABLED;
+    }
+
+    @Override
+    public Component component(GeyserConnection connection) {
+        return DropdownComponent.of(CooldownType.OPTION_DESCRIPTION, OPTIONS, value().ordinal());
+    }
+
+    @Override
+    public void onFormResponse(@NonNull Object response) throws IllegalArgumentException {
+        update(CooldownType.VALUES[(int) response]);
+    }
+
+    private static CooldownType configSetting(GeyserSession session) {
+        return session.getGeyser().getConfig().getShowCooldown();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/preference/CustomSkullsPreference.java b/core/src/main/java/org/geysermc/geyser/preference/CustomSkullsPreference.java
new file mode 100644
index 00000000000..fdca24fd087
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/preference/CustomSkullsPreference.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.geysermc.cumulus.component.Component;
+import org.geysermc.cumulus.component.ToggleComponent;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+import org.geysermc.geyser.api.preference.BooleanPreference;
+import org.geysermc.geyser.api.preference.PreferenceKey;
+import org.geysermc.geyser.session.GeyserSession;
+
+public class CustomSkullsPreference extends BooleanPreference {
+
+    public static final PreferenceKey<Boolean> KEY = new PreferenceKey<>("geyser:show_custom_skulls");
+
+    public CustomSkullsPreference(GeyserSession session) {
+        super(configSetting(session));
+    }
+
+    @Override
+    public boolean isModifiable(GeyserConnection connection) {
+        return configSetting((GeyserSession) connection);
+    }
+
+    @Override
+    public Component component(GeyserConnection connection) {
+        return ToggleComponent.of("geyser.settings.option.customSkulls", value());
+    }
+
+    private static boolean configSetting(GeyserSession session) {
+        return session.getGeyser().getConfig().isAllowCustomSkulls();
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/preference/ShowCoordinatesPreference.java b/core/src/main/java/org/geysermc/geyser/preference/ShowCoordinatesPreference.java
new file mode 100644
index 00000000000..f760436db78
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/preference/ShowCoordinatesPreference.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2019-2023 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.preference;
+
+import org.geysermc.cumulus.component.Component;
+import org.geysermc.cumulus.component.ToggleComponent;
+import org.geysermc.geyser.api.connection.GeyserConnection;
+import org.geysermc.geyser.api.preference.BooleanPreference;
+import org.geysermc.geyser.api.preference.PreferenceKey;
+import org.geysermc.geyser.session.GeyserSession;
+
+public class ShowCoordinatesPreference extends BooleanPreference {
+
+    public static final PreferenceKey<Boolean> KEY = new PreferenceKey<>("geyser:show_coordinates");
+
+    public ShowCoordinatesPreference(GeyserSession session) {
+        super(isAllowed(session));
+    }
+
+    @Override
+    public boolean isModifiable(GeyserConnection connection) {
+        return isAllowed((GeyserSession) connection);
+    }
+
+    @Override
+    public Component component(GeyserConnection connection) {
+        return ToggleComponent.of("%createWorldScreen.showCoordinates", value());
+    }
+
+    @Override
+    public void onUpdate(GeyserConnection connection) {
+        boolean showCoordinates;
+        if (isModifiable(connection)) {
+            showCoordinates = value();
+        } else {
+            showCoordinates = false;
+        }
+
+        GeyserSession session = (GeyserSession) connection;
+        session.sendGameRule("showcoordinates", showCoordinates);
+    }
+
+    private static boolean isAllowed(GeyserSession session) {
+        return !session.isReducedDebugInfo() && session.getGeyser().getConfig().isShowCoordinates();
+    }
+}
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 dba4bd112e3..3ec677eaff9 100644
--- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
+++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
@@ -99,6 +99,8 @@
 import org.geysermc.api.util.InputMode;
 import org.geysermc.api.util.UiProfile;
 import org.geysermc.geyser.api.bedrock.camera.CameraShake;
+import org.geysermc.geyser.api.preference.Preference;
+import org.geysermc.geyser.api.preference.PreferenceKey;
 import org.geysermc.geyser.api.util.PlatformType;
 import org.geysermc.cumulus.form.Form;
 import org.geysermc.cumulus.form.util.FormBuilder;
@@ -1991,6 +1993,21 @@ public void removeFog(String... fogNameSpaces) {
         return Set.copyOf(this.appliedFog);
     }
 
+    @Override
+    public <T> void storePreference(@NonNull PreferenceKey<T> key, @NonNull Preference<T> preference) {
+        this.preferencesCache.register(key, preference);
+    }
+
+    @Override
+    public @NonNull <T> Preference<T> requirePreference(@NonNull PreferenceKey<T> key) throws IllegalArgumentException {
+        return this.preferencesCache.require(key);
+    }
+
+    @Override
+    public @NonNull <T> Optional<Preference<T>> getPreference(@NonNull PreferenceKey<T> key) {
+        return this.preferencesCache.get(key);
+    }
+
     public void addCommandEnum(String name, String enums) {
         softEnumPacket(name, SoftEnumUpdateType.ADD, enums);
     }
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/PreferencesCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/PreferencesCache.java
index 9e8597b0f54..d009a5d47f8 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/PreferencesCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/PreferencesCache.java
@@ -25,63 +25,84 @@
 
 package org.geysermc.geyser.session.cache;
 
+import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
 import lombok.Getter;
-import lombok.Setter;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.geysermc.geyser.api.preference.Preference;
+import org.geysermc.geyser.api.preference.PreferenceKey;
+import org.geysermc.geyser.configuration.CooldownType;
 import org.geysermc.geyser.configuration.GeyserConfiguration;
+import org.geysermc.geyser.preference.CooldownPreference;
+import org.geysermc.geyser.preference.CustomSkullsPreference;
+import org.geysermc.geyser.preference.ShowCoordinatesPreference;
 import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.util.CooldownUtils;
 
-@Getter
+import java.util.Map;
+import java.util.Optional;
+
 public class PreferencesCache {
     private final GeyserSession session;
 
-    /**
-     * True if the client prefers being shown their coordinates, regardless if they're being shown or not.
-     * This will be true everytime the client joins the server because neither the client nor server store the preference permanently.
-     */
-    @Setter
-    private boolean prefersShowCoordinates = true;
+    @Getter
+    private final Map<PreferenceKey<?>, Preference<?>> preferences = new Object2ObjectOpenHashMap<>();
 
-    /**
-     * If the client's preference will be ignored, this will return false.
-     */
-    private boolean allowShowCoordinates;
+    public PreferencesCache(GeyserSession session) {
+        this.session = session;
 
-    /**
-     * If the session wants custom skulls to be shown.
-     */
-    @Setter
-    private boolean prefersCustomSkulls;
+        register(CooldownPreference.KEY, new CooldownPreference(session));
+        register(CustomSkullsPreference.KEY, new CustomSkullsPreference(session));
+        register(ShowCoordinatesPreference.KEY, new ShowCoordinatesPreference(session));
+    }
 
-    /**
-     * Which CooldownType the client prefers. Initially set to {@link CooldownUtils#getDefaultShowCooldown()}.
-     */
-    @Setter
-    private CooldownUtils.CooldownType cooldownPreference = CooldownUtils.getDefaultShowCooldown();
+    public <T> void register(PreferenceKey<T> key, Preference<T> preference) {
+        if (preference == null) {
+            throw new IllegalArgumentException("preference cannot be null");
+        }
+        preferences.put(key, preference);
+    }
 
-    public PreferencesCache(GeyserSession session) {
-        this.session = session;
+    @SuppressWarnings("unchecked")
+    @NonNull
+    public <T> Preference<T> require(PreferenceKey<T> key) throws IllegalArgumentException {
+        Preference<T> preference = (Preference<T>) preferences.get(key);
+        if (preference == null) {
+            throw new IllegalArgumentException("preference with key " + key + " is not stored for session " + session.javaUuid());
+        }
+        return preference;
+    }
 
-        prefersCustomSkulls = session.getGeyser().getConfig().isAllowCustomSkulls();
+    @SuppressWarnings("unchecked")
+    @NonNull
+    public <T> Optional<Preference<T>> get(PreferenceKey<T> key) {
+        return Optional.ofNullable((Preference<T>) preferences.get(key));
     }
 
     /**
-     * Tell the client to hide or show the coordinates.
-     *
-     * If {@link #prefersShowCoordinates} is true, coordinates will be shown, unless either of the following conditions apply: <br>
-     * <br>
-     * {@link GeyserSession#reducedDebugInfo} is enabled
-     * {@link GeyserConfiguration#isShowCoordinates()} is disabled
+     * Tell the client to hide or show the coordinates. The client's preference will be overridden if either of the
+     * following are true:
+     * <br><br>
+     * {@link GeyserSession#isReducedDebugInfo} is enabled.<br>
+     * {@link GeyserConfiguration#isShowCoordinates()} is disabled.
      */
     public void updateShowCoordinates() {
-        allowShowCoordinates = !session.isReducedDebugInfo() && session.getGeyser().getConfig().isShowCoordinates();
-        session.sendGameRule("showcoordinates", allowShowCoordinates && prefersShowCoordinates);
+        Preference<Boolean> preference = require(ShowCoordinatesPreference.KEY);
+        // preference itself won't be any different, but trigger an update anyway in case
+        // reduced-debug-info has changed or the config has changed
+        preference.onUpdate(session);
     }
 
-    /**
-     * @return true if the session prefers custom skulls, and the config allows them.
-     */
-    public boolean showCustomSkulls() {
-        return prefersCustomSkulls && session.getGeyser().getConfig().isAllowCustomSkulls();
+    public boolean getEffectiveShowSkulls() {
+        if (!session.getGeyser().getConfig().isAllowCustomSkulls()) {
+            return false;
+        }
+        return require(CustomSkullsPreference.KEY).value();
+    }
+
+
+    public CooldownType getEffectiveCooldown() {
+        if (session.getGeyser().getConfig().getShowCooldown() == CooldownType.DISABLED) {
+            return CooldownType.DISABLED;
+        }
+        return require(CooldownPreference.KEY).value();
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaBlockEntityDataTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaBlockEntityDataTranslator.java
index e1a94f02caa..ac735ee07da 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaBlockEntityDataTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaBlockEntityDataTranslator.java
@@ -63,7 +63,7 @@ public void translate(GeyserSession session, ClientboundBlockEntityDataPacket pa
         BlockEntityUtils.updateBlockEntity(session, translator.getBlockEntityTag(type, position.getX(), position.getY(), position.getZ(),
                 packet.getNbt(), blockState), packet.getPosition());
         // Check for custom skulls.
-        if (session.getPreferencesCache().showCustomSkulls() && packet.getNbt() != null && packet.getNbt().contains("SkullOwner")) {
+        if (session.getPreferencesCache().getEffectiveShowSkulls() && packet.getNbt() != null && packet.getNbt().contains("SkullOwner")) {
             SkullBlockEntityTranslator.translateSkull(session, packet.getNbt(), position.getX(), position.getY(), position.getZ(), blockState);
         }
 
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java
index a6d5fe09c9a..e1b44c9a83d 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaLevelChunkWithLightTranslator.java
@@ -90,6 +90,7 @@ public void translate(GeyserSession session, ClientboundLevelChunkWithLightPacke
         int yOffset = session.getChunkCache().getChunkMinY();
         int chunkSize = session.getChunkCache().getChunkHeightY();
         int biomeGlobalPalette = session.getBiomeGlobalPalette();
+        boolean customSkulls = session.getPreferencesCache().getEffectiveShowSkulls();
 
         DataPalette[] javaChunks = new DataPalette[chunkSize];
         DataPalette[] javaBiomes = new DataPalette[chunkSize];
@@ -283,7 +284,7 @@ public void translate(GeyserSession session, ClientboundLevelChunkWithLightPacke
                 bedrockBlockEntities.add(blockEntityTranslator.getBlockEntityTag(type, x + chunkBlockX, y, z + chunkBlockZ, tag, blockState));
 
                 // Check for custom skulls
-                if (session.getPreferencesCache().showCustomSkulls() && type == BlockEntityType.SKULL && tag != null && tag.contains("SkullOwner")) {
+                if (customSkulls && type == BlockEntityType.SKULL && tag != null && tag.contains("SkullOwner")) {
                     SkullBlockEntityTranslator.translateSkull(session, tag, x + chunkBlockX, y, z + chunkBlockZ, blockState);
                 }
             }
diff --git a/core/src/main/java/org/geysermc/geyser/util/CooldownUtils.java b/core/src/main/java/org/geysermc/geyser/util/CooldownUtils.java
index c00e389fd21..b7346382b7c 100644
--- a/core/src/main/java/org/geysermc/geyser/util/CooldownUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/CooldownUtils.java
@@ -26,7 +26,7 @@
 package org.geysermc.geyser.util;
 
 import org.cloudburstmc.protocol.bedrock.packet.SetTitlePacket;
-import lombok.Getter;
+import org.geysermc.geyser.configuration.CooldownType;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.session.cache.PreferencesCache;
 import org.geysermc.geyser.text.ChatColor;
@@ -40,8 +40,8 @@
 public class CooldownUtils {
     private static CooldownType DEFAULT_SHOW_COOLDOWN;
 
-    public static void setDefaultShowCooldown(String showCooldown) {
-        DEFAULT_SHOW_COOLDOWN = CooldownType.getByName(showCooldown);
+    public static void setDefaultShowCooldown(CooldownType showCooldown) {
+        DEFAULT_SHOW_COOLDOWN = showCooldown;
     }
 
     public static CooldownType getDefaultShowCooldown() {
@@ -54,7 +54,7 @@ public static CooldownType getDefaultShowCooldown() {
      */
     public static void sendCooldown(GeyserSession session) {
         if (DEFAULT_SHOW_COOLDOWN == CooldownType.DISABLED) return;
-        CooldownType sessionPreference = session.getPreferencesCache().getCooldownPreference();
+        CooldownType sessionPreference = session.getPreferencesCache().getEffectiveCooldown();
         if (sessionPreference == CooldownType.DISABLED) return;
 
         if (session.getAttackSpeed() == 0.0 || session.getAttackSpeed() > 20) return; // 0.0 usually happens on login and causes issues with visuals; anything above 20 means a plugin like OldCombatMechanics is being used
@@ -145,32 +145,4 @@ private static String getTitle(GeyserSession session) {
         return builder.toString();
     }
 
-    @Getter
-    public enum CooldownType {
-        TITLE,
-        ACTIONBAR,
-        DISABLED;
-
-        public static final CooldownType[] VALUES = values();
-
-        /**
-         * Convert the CooldownType string (from config) to the enum, DISABLED on fail
-         *
-         * @param name CooldownType string
-         *
-         * @return The converted CooldownType
-         */
-        public static CooldownType getByName(String name) {
-            if (name.equalsIgnoreCase("true")) { // Backwards config compatibility
-                return CooldownType.TITLE;
-            }
-
-            for (CooldownType type : VALUES) {
-                if (type.name().equalsIgnoreCase(name)) {
-                    return type;
-                }
-            }
-            return DISABLED;
-        }
-    }
 }
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 5957fb9d98f..1b810da83cc 100644
--- a/core/src/main/java/org/geysermc/geyser/util/SettingsUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/SettingsUtils.java
@@ -27,7 +27,9 @@
 
 import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode;
 import com.github.steveice10.mc.protocol.data.game.setting.Difficulty;
+import it.unimi.dsi.fastutil.Pair;
 import org.geysermc.cumulus.component.DropdownComponent;
+import org.geysermc.cumulus.component.LabelComponent;
 import org.geysermc.cumulus.form.CustomForm;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.level.GameRule;
@@ -36,6 +38,8 @@
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.text.MinecraftLocale;
 
+import java.util.Objects;
+
 public class SettingsUtils {
     /**
      * Build a settings form for the given session and store it for later
@@ -51,29 +55,21 @@ public static CustomForm buildForm(GeyserSession session) {
                 .title("geyser.settings.title.main")
                 .iconPath("textures/ui/settings_glyph_color_2x.png");
 
-        // Only show the client title if any of the client settings are available
-        boolean showClientSettings = session.getPreferencesCache().isAllowShowCoordinates()
-                || CooldownUtils.getDefaultShowCooldown() != CooldownUtils.CooldownType.DISABLED
-                || session.getGeyser().getConfig().isAllowCustomSkulls();
 
+        var preferences = session.getPreferencesCache().getPreferences().values()
+            .stream()
+            .filter(pref -> pref.isModifiable(session)) // only show modifiable preferences
+            .map(pref -> Pair.of(pref, pref.component(session))) // compute components
+            .filter(p -> !(p.value() instanceof LabelComponent)) // skip any that gave us a label
+            .toList();
+
+        // Only show the client title if any of the client settings are available
+        boolean showClientSettings = !preferences.isEmpty();
         if (showClientSettings) {
             builder.label("geyser.settings.title.client");
 
-            // Client can only see its coordinates if reducedDebugInfo is disabled and coordinates are enabled in geyser config.
-            if (session.getPreferencesCache().isAllowShowCoordinates()) {
-                builder.toggle("%createWorldScreen.showCoordinates", session.getPreferencesCache().isPrefersShowCoordinates());
-            }
-
-            if (CooldownUtils.getDefaultShowCooldown() != CooldownUtils.CooldownType.DISABLED) {
-                DropdownComponent.Builder cooldownDropdown = DropdownComponent.builder("options.attackIndicator");
-                cooldownDropdown.option("options.attack.crosshair", session.getPreferencesCache().getCooldownPreference() == CooldownUtils.CooldownType.TITLE);
-                cooldownDropdown.option("options.attack.hotbar", session.getPreferencesCache().getCooldownPreference() == CooldownUtils.CooldownType.ACTIONBAR);
-                cooldownDropdown.option("options.off", session.getPreferencesCache().getCooldownPreference() == CooldownUtils.CooldownType.DISABLED);
-                builder.dropdown(cooldownDropdown);
-            }
-
-            if (session.getGeyser().getConfig().isAllowCustomSkulls()) {
-                builder.toggle("geyser.settings.option.customSkulls", session.getPreferencesCache().isPrefersCustomSkulls());
+            for (var preferenceData : preferences) {
+                builder.component(preferenceData.value());
             }
         }
 
@@ -112,19 +108,9 @@ public static CustomForm buildForm(GeyserSession session) {
 
         builder.validResultHandler((response) -> {
             if (showClientSettings) {
-                // Client can only see its coordinates if reducedDebugInfo is disabled and coordinates are enabled in geyser config.
-                if (session.getPreferencesCache().isAllowShowCoordinates()) {
-                    session.getPreferencesCache().setPrefersShowCoordinates(response.next());
-                    session.getPreferencesCache().updateShowCoordinates();
-                }
-
-                if (CooldownUtils.getDefaultShowCooldown() != CooldownUtils.CooldownType.DISABLED) {
-                    CooldownUtils.CooldownType cooldownType = CooldownUtils.CooldownType.VALUES[(int) response.next()];
-                    session.getPreferencesCache().setCooldownPreference(cooldownType);
-                }
-
-                if (session.getGeyser().getConfig().isAllowCustomSkulls()) {
-                    session.getPreferencesCache().setPrefersCustomSkulls(response.next());
+                for (var preferenceData : preferences) {
+                    Object value = Objects.requireNonNull(response.next(), "response for preference " + preferenceData.key());
+                    preferenceData.key().onFormResponse(value);
                 }
             }