diff --git a/spark-neoforge/build.gradle b/spark-neoforge/build.gradle
index e0018eda..a2a246c1 100644
--- a/spark-neoforge/build.gradle
+++ b/spark-neoforge/build.gradle
@@ -1,6 +1,6 @@
 plugins {
     id 'com.gradleup.shadow' version '8.3.0'
-    id 'net.neoforged.moddev' version '1.0.19'
+    id 'net.neoforged.moddev' version '1.0.21'
 }
 
 tasks.withType(JavaCompile).configureEach {
@@ -15,7 +15,7 @@ java {
 }
 
 neoForge {
-    version = "21.1.22"
+    version = "21.2.0-beta"
     validateAccessTransformers = true
 
     runs {
diff --git a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeClientCommandSender.java b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeClientCommandSender.java
new file mode 100644
index 00000000..c269b769
--- /dev/null
+++ b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeClientCommandSender.java
@@ -0,0 +1,71 @@
+/*
+ * This file is part of spark.
+ *
+ *  Copyright (c) lucko (Luck) <luck@lucko.me>
+ *  Copyright (c) contributors
+ *
+ *  This program is free software: you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation, either version 3 of the License, or
+ *  (at your option) any later version.
+ *
+ *  This program is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package me.lucko.spark.neoforge;
+
+import me.lucko.spark.common.command.sender.AbstractCommandSender;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.network.chat.Component.Serializer;
+import net.minecraft.network.chat.MutableComponent;
+import net.minecraft.world.entity.Entity;
+
+import java.util.Objects;
+import java.util.UUID;
+
+public class NeoForgeClientCommandSender extends AbstractCommandSender<CommandSourceStack> {
+    public NeoForgeClientCommandSender(CommandSourceStack source) {
+        super(source);
+    }
+
+    @Override
+    public String getName() {
+        return this.delegate.getTextName();
+    }
+
+    @Override
+    public UUID getUniqueId() {
+        Entity entity = this.delegate.getEntity();
+        if (entity instanceof LocalPlayer player) {
+            return player.getUUID();
+        }
+        return null;
+    }
+
+    @Override
+    public void sendMessage(Component message) {
+        MutableComponent component = Serializer.fromJson(GsonComponentSerializer.gson().serializeToTree(message), RegistryAccess.EMPTY);
+        Objects.requireNonNull(component, "component");
+        super.delegate.sendSystemMessage(component);
+    }
+
+    @Override
+    public boolean hasPermission(String permission) {
+        return true;
+    }
+
+    @Override
+    protected Object getObjectForComparison() {
+        return this.delegate.getEntity();
+    }
+}
diff --git a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeCommandSender.java b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeServerCommandSender.java
similarity index 62%
rename from spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeCommandSender.java
rename to spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeServerCommandSender.java
index 8cc97888..a2162eea 100644
--- a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeCommandSender.java
+++ b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeServerCommandSender.java
@@ -21,47 +21,39 @@
 package me.lucko.spark.neoforge;
 
 import me.lucko.spark.common.command.sender.AbstractCommandSender;
-import me.lucko.spark.neoforge.plugin.NeoForgeSparkPlugin;
+import me.lucko.spark.neoforge.plugin.NeoForgeServerSparkPlugin;
 import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
-import net.minecraft.commands.CommandSource;
+import net.minecraft.commands.CommandSourceStack;
 import net.minecraft.core.RegistryAccess;
 import net.minecraft.network.chat.Component.Serializer;
 import net.minecraft.network.chat.MutableComponent;
-import net.minecraft.server.MinecraftServer;
-import net.minecraft.server.rcon.RconConsoleSource;
-import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.entity.Entity;
 
 import java.util.Objects;
 import java.util.UUID;
 
-public class NeoForgeCommandSender extends AbstractCommandSender<CommandSource> {
-    private final NeoForgeSparkPlugin plugin;
+public class NeoForgeServerCommandSender extends AbstractCommandSender<CommandSourceStack> {
+    private final NeoForgeServerSparkPlugin plugin;
 
-    public NeoForgeCommandSender(CommandSource source, NeoForgeSparkPlugin plugin) {
-        super(source);
+    public NeoForgeServerCommandSender(CommandSourceStack commandSource, NeoForgeServerSparkPlugin plugin) {
+        super(commandSource);
         this.plugin = plugin;
     }
 
     @Override
     public String getName() {
-        if (super.delegate instanceof Player) {
-            return ((Player) super.delegate).getGameProfile().getName();
-        } else if (super.delegate instanceof MinecraftServer) {
+        String name = this.delegate.getTextName();
+        if (this.delegate.getEntity() != null && name.equals("Server")) {
             return "Console";
-        } else if (super.delegate instanceof RconConsoleSource) {
-            return "RCON Console";
-        } else {
-            return "unknown:" + super.delegate.getClass().getSimpleName();
         }
+        return name;
     }
 
     @Override
     public UUID getUniqueId() {
-        if (super.delegate instanceof Player) {
-            return ((Player) super.delegate).getUUID();
-        }
-        return null;
+        Entity entity = this.delegate.getEntity();
+        return entity != null ? entity.getUUID() : null;
     }
 
     @Override
@@ -73,6 +65,11 @@ public void sendMessage(Component message) {
 
     @Override
     public boolean hasPermission(String permission) {
-        return this.plugin.hasPermission(super.delegate, permission);
+        return this.plugin.hasPermission(this.delegate, permission);
+    }
+
+    @Override
+    protected Object getObjectForComparison() {
+        return this.delegate.getEntity();
     }
 }
diff --git a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeWorldInfoProvider.java b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeWorldInfoProvider.java
index a234bc84..73b9e7b0 100644
--- a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeWorldInfoProvider.java
+++ b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/NeoForgeWorldInfoProvider.java
@@ -128,20 +128,19 @@ public GameRulesResult pollGameRules() {
             GameRulesResult data = new GameRulesResult();
             Iterable<ServerLevel> levels = this.server.getAllLevels();
 
-            GameRules.visitGameRuleTypes(new GameRules.GameRuleTypeVisitor() {
-                @Override
-                public <T extends GameRules.Value<T>> void visit(GameRules.Key<T> key, GameRules.Type<T> type) {
-                    String defaultValue = type.createRule().serialize();
-                    data.putDefault(key.getId(), defaultValue);
+            for (ServerLevel level : levels) {
+                level.getGameRules().visitGameRuleTypes(new GameRules.GameRuleTypeVisitor() {
+                    @Override
+                    public <T extends GameRules.Value<T>> void visit(GameRules.Key<T> key, GameRules.Type<T> type) {
+                        String defaultValue = type.createRule().serialize();
+                        data.putDefault(key.getId(), defaultValue);
 
-                    for (ServerLevel level : levels) {
                         String levelName = level.dimension().location().getPath();
-
                         String value = level.getGameRules().getRule(key).serialize();
                         data.put(key.getId(), levelName, value);
                     }
-                }
-            });
+                });
+            }
 
             return data;
         }
@@ -203,28 +202,8 @@ public ChunksResult<ForgeChunkInfo> pollChunks() {
 
         @Override
         public GameRulesResult pollGameRules() {
-            ClientLevel level = this.client.level;
-            if (level == null) {
-                return null;
-            }
-
-            GameRulesResult data = new GameRulesResult();
-
-            String levelName = level.dimension().location().getPath();
-            GameRules levelRules = level.getGameRules();
-
-            GameRules.visitGameRuleTypes(new GameRules.GameRuleTypeVisitor() {
-                @Override
-                public <T extends GameRules.Value<T>> void visit(GameRules.Key<T> key, GameRules.Type<T> type) {
-                    String defaultValue = type.createRule().serialize();
-                    data.putDefault(key.getId(), defaultValue);
-
-                    String value = levelRules.getRule(key).serialize();
-                    data.put(key.getId(), levelName, value);
-                }
-            });
-
-            return data;
+            // Not available on client since 24w39a
+            return null;
         }
 
         @Override
diff --git a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeClientSparkPlugin.java b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeClientSparkPlugin.java
index b779af81..539bc004 100644
--- a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeClientSparkPlugin.java
+++ b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeClientSparkPlugin.java
@@ -31,17 +31,17 @@
 import me.lucko.spark.common.sampler.ThreadDumper;
 import me.lucko.spark.common.tick.TickHook;
 import me.lucko.spark.common.tick.TickReporter;
-import me.lucko.spark.neoforge.NeoForgeCommandSender;
+import me.lucko.spark.neoforge.NeoForgeClientCommandSender;
 import me.lucko.spark.neoforge.NeoForgePlatformInfo;
 import me.lucko.spark.neoforge.NeoForgeSparkMod;
 import me.lucko.spark.neoforge.NeoForgeTickHook;
 import me.lucko.spark.neoforge.NeoForgeTickReporter;
 import me.lucko.spark.neoforge.NeoForgeWorldInfoProvider;
 import net.minecraft.client.Minecraft;
-import net.minecraft.commands.CommandSource;
 import net.minecraft.commands.CommandSourceStack;
 import net.neoforged.bus.api.SubscribeEvent;
 import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
+import net.neoforged.neoforge.client.ClientCommandHandler;
 import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
 import net.neoforged.neoforge.common.NeoForge;
 
@@ -84,7 +84,7 @@ public int run(CommandContext<CommandSourceStack> context) throws CommandSyntaxE
             return 0;
         }
 
-        this.platform.executeCommand(new NeoForgeCommandSender(context.getSource().getEntity(), this), args);
+        this.platform.executeCommand(new NeoForgeClientCommandSender(context.getSource()), args);
         return Command.SINGLE_SUCCESS;
     }
 
@@ -95,17 +95,12 @@ public CompletableFuture<Suggestions> getSuggestions(CommandContext<CommandSourc
             return Suggestions.empty();
         }
 
-        return generateSuggestions(new NeoForgeCommandSender(context.getSource().getEntity(), this), args, builder);
+        return generateSuggestions(new NeoForgeClientCommandSender(context.getSource()), args, builder);
     }
 
     @Override
-    public boolean hasPermission(CommandSource sender, String permission) {
-        return true;
-    }
-
-    @Override
-    public Stream<NeoForgeCommandSender> getCommandSenders() {
-        return Stream.of(new NeoForgeCommandSender(this.minecraft.player, this));
+    public Stream<NeoForgeClientCommandSender> getCommandSenders() {
+        return Stream.of(new NeoForgeClientCommandSender(ClientCommandHandler.getSource()));
     }
 
     @Override
diff --git a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeServerSparkPlugin.java b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeServerSparkPlugin.java
index 5a573a03..5d4630be 100644
--- a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeServerSparkPlugin.java
+++ b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeServerSparkPlugin.java
@@ -35,15 +35,14 @@
 import me.lucko.spark.common.sampler.ThreadDumper;
 import me.lucko.spark.common.tick.TickHook;
 import me.lucko.spark.common.tick.TickReporter;
-import me.lucko.spark.neoforge.NeoForgeCommandSender;
 import me.lucko.spark.neoforge.NeoForgePlatformInfo;
 import me.lucko.spark.neoforge.NeoForgePlayerPingProvider;
+import me.lucko.spark.neoforge.NeoForgeServerCommandSender;
 import me.lucko.spark.neoforge.NeoForgeServerConfigProvider;
 import me.lucko.spark.neoforge.NeoForgeSparkMod;
 import me.lucko.spark.neoforge.NeoForgeTickHook;
 import me.lucko.spark.neoforge.NeoForgeTickReporter;
 import me.lucko.spark.neoforge.NeoForgeWorldInfoProvider;
-import net.minecraft.commands.CommandSource;
 import net.minecraft.commands.CommandSourceStack;
 import net.minecraft.server.MinecraftServer;
 import net.minecraft.server.level.ServerPlayer;
@@ -171,8 +170,7 @@ public int run(CommandContext<CommandSourceStack> context) throws CommandSyntaxE
             return 0;
         }
 
-        CommandSource source = context.getSource().getEntity() != null ? context.getSource().getEntity() : context.getSource().getServer();
-        this.platform.executeCommand(new NeoForgeCommandSender(source, this), args);
+        this.platform.executeCommand(new NeoForgeServerCommandSender(context.getSource(), this), args);
         return Command.SINGLE_SUCCESS;
     }
 
@@ -183,12 +181,12 @@ public CompletableFuture<Suggestions> getSuggestions(CommandContext<CommandSourc
             return Suggestions.empty();
         }
 
-        return generateSuggestions(new NeoForgeCommandSender(context.getSource().getPlayerOrException(), this), args, builder);
+        return generateSuggestions(new NeoForgeServerCommandSender(context.getSource(), this), args, builder);
     }
 
-    @Override
-    public boolean hasPermission(CommandSource sender, String permission) {
-        if (sender instanceof ServerPlayer) {
+    public boolean hasPermission(CommandSourceStack source, String permission) {
+        ServerPlayer player = source.getPlayer();
+        if (player != null) {
             if (permission.equals("spark")) {
                 permission = "spark.all";
             }
@@ -197,18 +195,18 @@ public boolean hasPermission(CommandSource sender, String permission) {
             if (permissionNode == null) {
                 throw new IllegalStateException("spark permission not registered: " + permission);
             }
-            return PermissionAPI.getPermission((ServerPlayer) sender, permissionNode);
+            return PermissionAPI.getPermission(player, permissionNode);
         } else {
             return true;
         }
     }
 
     @Override
-    public Stream<NeoForgeCommandSender> getCommandSenders() {
+    public Stream<NeoForgeServerCommandSender> getCommandSenders() {
         return Stream.concat(
-            this.server.getPlayerList().getPlayers().stream(),
-            Stream.of(this.server)
-        ).map(sender -> new NeoForgeCommandSender(sender, this));
+            this.server.getPlayerList().getPlayers().stream().map(ServerPlayer::createCommandSourceStack),
+            Stream.of(this.server.createCommandSourceStack())
+        ).map(stack -> new NeoForgeServerCommandSender(stack, this));
     }
 
     @Override
diff --git a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeSparkPlugin.java b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeSparkPlugin.java
index 256f90a4..10c7a683 100644
--- a/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeSparkPlugin.java
+++ b/spark-neoforge/src/main/java/me/lucko/spark/neoforge/plugin/NeoForgeSparkPlugin.java
@@ -38,7 +38,6 @@
 import me.lucko.spark.common.util.SparkThreadFactory;
 import me.lucko.spark.neoforge.NeoForgeClassSourceLookup;
 import me.lucko.spark.neoforge.NeoForgeSparkMod;
-import net.minecraft.commands.CommandSource;
 import net.neoforged.fml.ModList;
 import net.neoforged.neoforgespi.language.IModInfo;
 import org.apache.logging.log4j.LogManager;
@@ -76,8 +75,6 @@ public void disable() {
         this.scheduler.shutdown();
     }
 
-    public abstract boolean hasPermission(CommandSource sender, String permission);
-
     @Override
     public String getVersion() {
         return this.mod.getVersion();