diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index dd40f05..92503bf 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,10 +1,5 @@
 name: Maven CI
-
-on:
-  push:
-    branches: [ master ]
-  pull_request:
-    branches: [ master ]
+on: [push, pull_request]
 
 jobs:
   build:
@@ -31,3 +26,4 @@ jobs:
       with:
         name: CommandSpy
         path: target/CommandSpy.jar
+        compression-level: 0
diff --git a/.gitignore b/.gitignore
index f845591..d3ee570 100755
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ target/
 .classpath
 .project
 *.iml
+.theia/
+run/
\ No newline at end of file
diff --git a/README.md b/README.md
index 57d3662..bb7b021 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@ The plugin is created for the Kaboom server.
 
 ## Commands
 
-| Command | Alias | Permission | Description |
-| ------- | ----- | ---------- | ----------- |
-|/commandspy | /c, /cs, /cspy | commandspy.command | Allows you to spy on players' commands|
+| Command     | Alias          | Permission         | Description                            |
+|-------------|----------------|--------------------|----------------------------------------|
+| /commandspy | /c, /cs, /cspy | commandspy.command | Allows you to spy on players' commands |
 
 ## Compiling
 
diff --git a/pom.xml b/pom.xml
index 8c686a8..61c531f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
         <dependency>
             <groupId>io.papermc.paper</groupId>
             <artifactId>paper-api</artifactId>
-            <version>1.18.2-R0.1-SNAPSHOT</version>
+            <version>1.20.4-R0.1-SNAPSHOT</version>
             <scope>provided</scope>
         </dependency>
     </dependencies>
@@ -30,10 +30,22 @@
     <build>
         <finalName>${project.artifactId}</finalName>
         <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-jar-plugin</artifactId>
+                <version>3.4.2</version>
+                <configuration>
+                    <archive>
+                        <manifestEntries>
+                            <paperweight-mappings-namespace>mojang</paperweight-mappings-namespace>
+                        </manifestEntries>
+                    </archive>
+                </configuration>
+            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-checkstyle-plugin</artifactId>
-                <version>3.1.2</version>
+                <version>3.6.0</version>
                 <executions>
                     <execution>
                         <id>checkstyle</id>
diff --git a/src/main/java/pw/kaboom/commandspy/CommandSpyState.java b/src/main/java/pw/kaboom/commandspy/CommandSpyState.java
new file mode 100644
index 0000000..676ba89
--- /dev/null
+++ b/src/main/java/pw/kaboom/commandspy/CommandSpyState.java
@@ -0,0 +1,127 @@
+package pw.kaboom.commandspy;
+
+import com.google.common.io.Files;
+import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
+import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+
+import java.io.*;
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.StampedLock;
+import java.util.stream.Collectors;
+
+public final class CommandSpyState {
+    private static final Logger LOGGER = JavaPlugin.getPlugin(Main.class).getSLF4JLogger();
+
+    private final ObjectOpenHashSet<UUID> users = new ObjectOpenHashSet<>();
+    private final StampedLock usersLock = new StampedLock();
+    private final AtomicBoolean dirty = new AtomicBoolean();
+    private final File file;
+
+    public CommandSpyState(final @NotNull File file) {
+        this.file = file;
+
+        try {
+            this.load();
+        } catch (final FileNotFoundException exception) {
+            try {
+                this.save(); // Create file if it doesn't exist
+            } catch (IOException ignored) {
+            }
+        } catch (final IOException exception) {
+            LOGGER.error("Failed to load state file:", exception);
+        }
+    }
+
+    private void load() throws IOException {
+        final InputStream reader = new BufferedInputStream(new FileInputStream(this.file));
+
+        int read;
+        final ByteBuffer buffer = ByteBuffer.wrap(new byte[16]);
+
+        // Loop until we read less than 16 bytes
+        while ((read = reader.readNBytes(buffer.array(), 0, 16)) == 16) {
+            this.users.add(new UUID(buffer.getLong(0), buffer.getLong(8)));
+        }
+
+        reader.close();
+        if (read != 0) {
+            throw new IOException("Found " + read + " bytes extra whilst reading file");
+        }
+    }
+
+    private void save() throws IOException {
+        Files.createParentDirs(this.file);
+        final OutputStream writer = new BufferedOutputStream(new FileOutputStream(this.file));
+        final ByteBuffer buffer = ByteBuffer.wrap(new byte[16]);
+
+        final long stamp = this.usersLock.readLock();
+        for (final UUID uuid : this.users) {
+            buffer.putLong(0, uuid.getMostSignificantBits());
+            buffer.putLong(8, uuid.getLeastSignificantBits());
+            writer.write(buffer.array());
+        }
+        this.usersLock.unlockRead(stamp);
+
+        writer.flush();
+        writer.close();
+    }
+
+    public void trySave() {
+        // If the state is not dirty, then we don't need to do anything.
+        if (!this.dirty.compareAndExchange(true, false)) {
+            return;
+        }
+
+        try {
+            this.save();
+        } catch (final IOException exception) {
+            LOGGER.error("Failed to save state file:", exception);
+        }
+    }
+
+    public boolean getCommandSpyState(final @NotNull UUID playerUUID) {
+        final long stamp = this.usersLock.readLock();
+        final boolean result = this.users.contains(playerUUID);
+        this.usersLock.unlockRead(stamp);
+
+        return result;
+    }
+
+    public void setCommandSpyState(final @NotNull UUID playerUUID, final boolean state) {
+        final long stamp = this.usersLock.writeLock();
+
+        final boolean dirty;
+        if (state) {
+            dirty = this.users.add(playerUUID);
+        } else {
+            dirty = this.users.remove(playerUUID);
+        }
+
+        this.usersLock.unlockWrite(stamp);
+        if (dirty) {
+            this.dirty.set(true);
+        }
+    }
+
+    public void broadcastSpyMessage(final @NotNull Component message) {
+        // Raw access here, so we can get more performance by not locking/unlocking over and over
+        final long stamp = this.usersLock.readLock();
+        final Collection<Player> players = Bukkit.getOnlinePlayers()
+                .stream()
+                .filter(p -> this.users.contains(p.getUniqueId()))
+                .collect(Collectors.toUnmodifiableSet());
+        this.usersLock.unlockRead(stamp);
+
+        for (final Player recipient : players) {
+            recipient.sendMessage(message);
+        }
+    }
+}
diff --git a/src/main/java/pw/kaboom/commandspy/Main.java b/src/main/java/pw/kaboom/commandspy/Main.java
index 7aab4d7..a5d4f40 100644
--- a/src/main/java/pw/kaboom/commandspy/Main.java
+++ b/src/main/java/pw/kaboom/commandspy/Main.java
@@ -1,120 +1,188 @@
 package pw.kaboom.commandspy;
 
-import java.util.Set;
-import java.util.UUID;
-
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
 import org.bukkit.Bukkit;
 import org.bukkit.command.Command;
 import org.bukkit.command.CommandExecutor;
 import org.bukkit.command.CommandSender;
-import org.bukkit.command.ConsoleCommandSender;
-import org.bukkit.configuration.file.FileConfiguration;
 import org.bukkit.entity.Player;
 import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
 import org.bukkit.event.Listener;
 import org.bukkit.event.block.SignChangeEvent;
 import org.bukkit.event.player.PlayerCommandPreprocessEvent;
-import org.bukkit.plugin.Plugin;
 import org.bukkit.plugin.java.JavaPlugin;
-import net.kyori.adventure.text.Component;
-import net.kyori.adventure.text.format.NamedTextColor;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.File;
+import java.util.UUID;
 
 public final class Main extends JavaPlugin implements CommandExecutor, Listener {
-    private FileConfiguration config;
+    private CommandSpyState config;
 
     @Override
     public void onEnable() {
-        config = getConfig();
+        this.config = new CommandSpyState(new File(this.getDataFolder(), "state.bin"));
+
+        //noinspection DataFlowIssue
         this.getCommand("commandspy").setExecutor(this);
         this.getServer().getPluginManager().registerEvents(this, this);
+
+        // Save the state every 30 seconds
+        Bukkit.getScheduler().runTaskTimerAsynchronously(this, this.config::trySave, 600L, 600L);
     }
 
-    private void enableCommandSpy(final Player player) {
-        config.set(player.getUniqueId().toString(), true);
-        saveConfig();
-        player.sendMessage(Component.text("Successfully enabled CommandSpy"));
+    @Override
+    public void onDisable() {
+        this.config.trySave();
     }
 
-    private void disableCommandSpy(final Player player) {
-        config.set(player.getUniqueId().toString(), null);
-        saveConfig();
-        player.sendMessage(Component.text("Successfully disabled CommandSpy"));
+    private void updateCommandSpyState(final @NotNull Player target,
+                                       final @NotNull CommandSender source, final boolean state) {
+        this.config.setCommandSpyState(target.getUniqueId(), state);
+
+        final Component stateString = Component.text(state ? "enabled" : "disabled");
+
+        target.sendMessage(Component.empty()
+                .append(Component.text("Successfully "))
+                .append(stateString)
+                .append(Component.text(" CommandSpy")));
+
+        if (source != target) {
+            source.sendMessage(Component.empty()
+                    .append(Component.text("Successfully "))
+                    .append(stateString)
+                    .append(Component.text(" CommandSpy for "))
+                    .append(target.name())
+            );
+        }
     }
 
     private NamedTextColor getTextColor(final Player player) {
-        if (config.contains(player.getUniqueId().toString())) {
+        if (this.config.getCommandSpyState(player.getUniqueId())) {
             return NamedTextColor.YELLOW;
         }
+
         return NamedTextColor.AQUA;
     }
 
     @Override
-    public boolean onCommand(final CommandSender sender, final Command cmd, final String label,
-                             final String[] args) {
-        if (sender instanceof ConsoleCommandSender) {
-            sender.sendMessage(Component.text("Command has to be run by a player"));
-            return true;
+    public boolean onCommand(final @NotNull CommandSender sender, final @NotNull Command cmd,
+                             final @NotNull String label, final String[] args) {
+
+        Player target = null;
+        Boolean state = null;
+
+        switch (args.length) {
+            case 0 -> {
+            }
+            case 1, 2 -> {
+                // Get the last argument as a state. Fail if we have 2 arguments.
+                state = getState(args[args.length - 1]);
+                if (state != null && args.length == 1) {
+                    break;
+                } else if (state == null && args.length == 2) {
+                    sender.sendMessage(Component.text("Usage: ", NamedTextColor.RED)
+                            .append(Component.text(cmd.getUsage().replace("<command>", label)))
+                    );
+                    return true;
+                }
+
+                // Get the first argument as a player. Fail if it can't be found.
+                target = getPlayer(args[0]);
+                if (target != null) {
+                    break;
+                }
+
+                sender.sendMessage(Component.empty()
+                        .append(Component.text("Player \""))
+                        .append(Component.text(args[0]))
+                        .append(Component.text("\" not found"))
+                );
+                return true;
+            }
+            default -> {
+                sender.sendMessage(Component.text("Usage: ", NamedTextColor.RED)
+                        .append(Component.text(cmd.getUsage().replace("<command>", label)))
+                );
+                return true;
+            }
         }
 
-        final Player player = (Player) sender;
+        if (target == null) {
+            if (!(sender instanceof final Player player)) {
+                sender.sendMessage(Component.text("Command has to be run by a player"));
+                return true;
+            }
 
-        if (args.length >= 1 && "on".equalsIgnoreCase(args[0])) {
-            enableCommandSpy(player);
-            return true;
+            target = player;
         }
-        if ((args.length >= 1 && "off".equalsIgnoreCase(args[0]))
-                || config.contains(player.getUniqueId().toString())) {
-            disableCommandSpy(player);
-            return true;
+
+        if (state == null) {
+            state = !this.config.getCommandSpyState(target.getUniqueId());
         }
-        enableCommandSpy(player);
+
+        this.updateCommandSpyState(target, sender, state);
+
         return true;
     }
 
-    @EventHandler
+    @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
     void onPlayerCommandPreprocess(final PlayerCommandPreprocessEvent event) {
-        if (event.isCancelled()) {
-            return;
-        }
-
         final Player player = event.getPlayer();
         final NamedTextColor color = getTextColor(player);
+
         final Component message = Component.text(player.getName(), color)
-            .append(Component.text(": "))
-            .append(Component.text(event.getMessage()));
+                .append(Component.text(": "))
+                .append(Component.text(event.getMessage()));
 
-        for (String uuidString : config.getKeys(false)) {
-            final UUID uuid = UUID.fromString(uuidString);
-            final Player recipient = Bukkit.getPlayer(uuid);
-
-            if (recipient == null) {
-                continue;
-            }
-            recipient.sendMessage(message);
-        }
+        this.config.broadcastSpyMessage(message);
     }
 
-    @EventHandler
+    @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
     void onSignChange(final SignChangeEvent event) {
         final Player player = event.getPlayer();
         final NamedTextColor color = getTextColor(player);
         Component message = Component.text(player.getName(), color)
-            .append(Component.text(" created a sign with contents:"));
+                .append(Component.text(" created a sign with contents:"));
 
-        for (Component line : event.lines()) {
+        for (final Component line : event.lines()) {
             message = message
-                .append(Component.text("\n "))
-                .append(line);
+                    .append(Component.text("\n "))
+                    .append(line);
         }
 
-        for (String uuidString : config.getKeys(false)) {
-            final UUID uuid = UUID.fromString(uuidString);
-            final Player recipient = Bukkit.getPlayer(uuid);
+        this.config.broadcastSpyMessage(message);
+    }
 
-            if (recipient == null) {
-                continue;
+    private static Player getPlayer(final String arg) {
+        final Player player = Bukkit.getPlayer(arg);
+        if (player != null) {
+            return player;
+        }
+
+        final UUID uuid;
+        try {
+            uuid = UUID.fromString(arg);
+        } catch (final IllegalArgumentException ignored) {
+            return null;
+        }
+
+        return Bukkit.getPlayer(uuid);
+    }
+
+    private static Boolean getState(final String arg) {
+        switch (arg) {
+            case "on", "enable" -> {
+                return true;
+            }
+            case "off", "disable" -> {
+                return false;
+            }
+            default -> {
+                return null;
             }
-            recipient.sendMessage(message);
         }
     }
 }
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index f95cb20..9ba5483 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -1,13 +1,13 @@
 name: CommandSpy
 main: pw.kaboom.commandspy.Main
 description: Plugin that allows you to spy on players' commands.
-api-version: 1.13
+api-version: '1.20'
 version: master
 folia-supported: true
 
 commands:
    commandspy:
-      aliases: [c,cs,cspy]
+      aliases: [ c, cs, cspy ]
       description: Allows you to spy on players' commands
-      usage: /commandspy
       permission: commandspy.command
+      usage: '/<command> [player] [on|enable|off|disable]'
diff --git a/suppressions.xml b/suppressions.xml
index 8990963..30ced2c 100644
--- a/suppressions.xml
+++ b/suppressions.xml
@@ -5,4 +5,6 @@
 
 <suppressions>
     <suppress checks="Javadoc" files="."/>
-</suppressions>
+    <suppress checks="MagicNumber" files="."/>
+    <suppress checks="MethodLength" files="."/>
+</suppressions>
\ No newline at end of file