User Tools

Site Tools


tutorial:persistent_states

Persistent State

Frequently in our mods we have information about the player, or about the state of the world that we would like to survive across player deaths and server restarts. For instance, let's say our mod keeps track of how many dirt blocks a player has broken:

int dirtBlocksBroken = 0;

And that the mod also keeps track of how many dirt blocks have been broken, on the server, in total:

int totalDirtBlocksBroken = 0;

How do we get fabric to save that information, or any other information that we want to keep (booleans, lists, strings) so that the next time a player loads the world, or logs in, we get the previous sessions data?

Simple Message

First, since the data will be saved on the 'server', (NOTE: that there is always a 'server' running even when you play offline, so don't be scared about that word), let's send a simple packet to the player when the server detects the player breaks a dirt block, and print it to the chat.

Modify your class which implements ModInitializer as follows:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "your_unique_mod_id_change_me_please";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    private Integer totalDirtBlocksBroken = 0;
 
    @Override
    public void onInitialize() {
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                // Increment the amount of dirt blocks that have been broken
                totalDirtBlocksBroken += 1;
 
                // Send a packet to the client
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(totalDirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}
  • The MOD_ID should be unique. Usually people use their mods name. Make sure to change it, but be mindful of the fact that you can only use lowercase letters and underscores or you'll get errors.
    • To send a packet we use: ServerPlayNetworking.send. It takes three arguments; The ServerPlayerEntity. An Identifer which is needed when sending a packet between server and client or client and server, and a PacketByteBuf which we are able to fill with arbritary data (that is: bools, ints, arrays, strings, and more). The PacketByteBuf is where we stuff the data we want to send across the line. In this case we send the total amount of dirt blocks that the server has seen broken.

Next modify your class which implements ClientModInitializer as follows:

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text; 
 
public class ExampleModClient implements ClientModInitializer {
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Total dirt blocks broken: " + totalDirtBlocksBroken));
            });
        });
    }
}
  • You can see the Identifier we use is the exact same instance as we used server side and then buf.readInt() is used to extract the data that was packed into the PackedByteBuf. You have to read out the data the same way you stuffed it in.

If you run the mod now, and open a world up, you should see a message every time you break a dirt/grass block. The number should increase by one every time. You may even notice that if you close the world and open it again, that the number keeps increasing from where you left off, but this is misleading. If you fully close out the Minecraft client and run it again, if you open that world and break a block, you should see that it starts from zero again. But why? Why doesn't it continue from where it left off?

The reason is that the number only ever lives in memory. It isn't saved anywhere. If it where, for instance to the hard-drive, then we could load it at start up and set the initial value to the value we ended up saving the last session, and therefore continue from where we left off. But we need to actually do the work of doing that: saving the value when minecraft closes and then loading it when minecraft starts up. There are many ways this could be done, but the built-in Minecraft way is to: implement a class which extends the PersistentState class.

Persistent State

First make a new file StateSaverAndLoader.java in the same folder as your class which implements ModInitializer.

import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
        return nbt;
    }
}

Note: writeNbt must be implemented when extending PersistentState. In the function, you get passed in an NbtCompound which we are supposed to pack with the data we want saved to disk. In our case, we moved the public Integer totalDirtBlocksBroken variable we had created earlier into this file.

  • NbtCompound doesn't just store Integers. It has functions for strings, arrays, bools, floats, importantly other NbtCompound's as you'll see soon enough, and even arbitrary bytes. That means, if you want to store some SuperCustomClass, what you should do is create a new NbtCompound and pack that new NbtCompound with the fields of your SuperCustomClass and then store it in the main NbtCompound you get passed in. (We're about to do just that!)

Next add the following function to that same file:

public class StateSaverAndLoader extends PersistentState {
 
    // ... (Previously written code)
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag, RegistryWrapper.WrapperLookup registryLookup) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
        return state;
    }
}

This function does the opposite of writeNbt. It takes in an NbtCompound (the same one we wrote in writeNbt) and a RegistryWrapper.WrapperLookup, creates a brand new StateSaverAndLoader and stuffs it with the data inside the NbtCompound.

  • Note: how we pull out the int we stored earlier with getInt and how the string we pass in is the same one we used in writeNbt.

Now we just need to add one more utility function which hooks everything up together. This function will take a MinecraftServer and from it, get the PersistentStateManager. PersistentStateManager has a function getOrCreate which will use our MOD_ID as a key to see if it has an instance of our StateSaverAndLoader or if it needs to create one. If it needs to create one, it'll call the function we just wrote createFromNbt passing in the previously saved-to-disk NbtCompound and a RegistryWrapper.WrapperLookup. Ultimately the function returns the StateSaverAndLoader for the given MinecraftServer.

public class StateSaverAndLoader extends PersistentState {
 
    // ... (Previously written code)
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // If there's no 'StateSaverAndLoader' yet create one
            StateSaverAndLoader::createFromNbt, // If there is a 'StateSaverAndLoader' NBT, parse it with 'createFromNbt'
            null // Supposed to be an 'DataFixTypes' enum, but we can just pass null
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (Note: arbitrary choice to use 'World.OVERWORLD' instead of 'World.END' or 'World.NETHER'.  Any work)
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // The first time the following 'getOrCreate' function is called, it creates a brand new 'StateSaverAndLoader' and
        // stores it inside the 'PersistentStateManager'. The subsequent calls to 'getOrCreate' pass in the saved
        // 'StateSaverAndLoader' NBT on disk to our function 'StateSaverAndLoader::createFromNbt'.
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // If state is not marked dirty, when Minecraft closes, 'writeNbt' won't be called and therefore nothing will be saved.
        // Technically it's 'cleaner' if you only mark state as dirty when there was actually a change, but the vast majority
        // of mod writers are just going to be confused when their data isn't being saved, and so it's best just to 'markDirty' for them.
        // Besides, it's literally just setting a bool to true, and the only time there's a 'cost' is when the file is written to disk when
        // there were no actual change to any of the mods state (INCREDIBLY RARE).
        state.markDirty();
 
        return state;
    }
}

Your StateSaverAndLoader file should look as follows:

import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag, RegistryWrapper.WrapperLookup registryLookup) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
        return state;
    }
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // If there's no 'StateSaverAndLoader' yet create one
            StateSaverAndLoader::createFromNbt, // If there is a 'StateSaverAndLoader' NBT, parse it with 'createFromNbt'
            null // Supposed to be an 'DataFixTypes' enum, but we can just pass null
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (Note: arbitrary choice to use 'World.OVERWORLD' instead of 'World.END' or 'World.NETHER'.  Any work)
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // The first time the following 'getOrCreate' function is called, it creates a brand new 'StateSaverAndLoader' and
        // stores it inside the 'PersistentStateManager'. The subsequent calls to 'getOrCreate' pass in the saved
        // 'StateSaverAndLoader' NBT on disk to our function 'StateSaverAndLoader::createFromNbt'.
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // If state is not marked dirty, when Minecraft closes, 'writeNbt' won't be called and therefore nothing will be saved.
        // Technically it's 'cleaner' if you only mark state as dirty when there was actually a change, but the vast majority
        // of mod writers are just going to be confused when their data isn't being saved, and so it's best just to 'markDirty' for them.
        // Besides, it's literally just setting a bool to true, and the only time there's a 'cost' is when the file is written to disk when
        // there were no actual change to any of the mods state (INCREDIBLY RARE).
        state.markDirty();
 
        return state;
    }
}

You'll also have to update your class which implements ModInitializer so that it's as follows:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "your_unique_mod_id_change_me_please";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    @Override
    public void onInitialize() {
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                // Send a packet to the client
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}

If you run your game now, you should see the counter going up, but now, if you fully close Minecraft, and open it again, you should see the number keeps increasing from where it left off.

What you might or might not expect is that totalDirtBlocksBroken is not specific to a player, that is, if you made a server with this mod, and a few people were running around breaking dirt blocks, all of them would increment that same number. This is fine for certain types of data we'd like to store for our mods, but most of the time, we want to store player-specific data. For instance, as we mentioned at the start of this article, what if we would like to store how many dirt blocks any specific player has broken?

Player Specific Persistent State

We can store player-specific data by extending what we already wrote.

First write a new class PlayerData.java (again placing it in the same folder as our class which implements ModInitializer).

  • Extremely important note: Since we'll be creating a HashMap which seemingly stores PlayerData's, it'll be tempting when you want to know some PlayerData client-side to use that hashmap, but, you won't be able to, because this data only exists on the server. If you want the client to see some or all the player's data. You'll have to send it through a packet. (We'll create an INITIAL_SYNC packet later which we will use to demonstrate this).
class PlayerData {
    public int dirtBlocksBroken = 0;
}

To simplify, we're just continuing with our simple example, but you could put any fields you'd like in the PlayerData (and it's expected that you will).

Next, we'll modify the top of our StateSaverAndLoader.java class as follows:

// ... (Previous imports)
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    // ... (Rest of the code)
 
}

Note: We create a HashMap of UUID's to PlayereData's. If you don't know what a hashmap does: you give them a 'key', in our case a UUID and they give you back something, in our case PlayerData. The reason we use UUID's is because every player that connects to our 'server' has a unique UUID that only they are associated with. This lets us differentiate between different players and lets us 'pull' the right data for them. (Or create it if it doesn't exist yet).

Let's add a utility function to StateSaverAndLoader which will take a LivingEntity and return the associated PlayerData in our 'HashMap'.

public class StateSaverAndLoader extends PersistentState {
 
    // ... (Previously written code)
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // Either get the player by the uuid, or we don't have data for him yet, make a new player state
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}
  • If our StateSaverAndLoader has never seen the UUID before then it makes sense that our HashMap won't return anything. That's why we use the convenience function computeIfAbsent so that in the case where that UUID doesn't exist in the hashmap yet, it will automatically create a new PlayerData and puts it in our hashmap, using that players specific UUID as the key so that it can be retrieved later.

Now update the class which implements ModInitializer as follows:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "your_unique_mod_id_change_me_please";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    @Override
    public void onInitialize() {
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // Send a packet to the client
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
                data.writeInt(playerState.dirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}

You'll also have to modify the class which implements ClientModInitializer as follows:

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text;
 
public class ExampleModClient implements ClientModInitializer {
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            int playerSpecificDirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Total dirt blocks broken: " + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("Player specific dirt blocks broken: " + playerSpecificDirtBlocksBroken));
            });
        });
    }
}

If you ran the client now, it would seem as if everything is working, but we are forgetting a crucial step: We haven't updated our writeNbt and createFromNbt to save and load our hashmap.

The updated functions are as follows:

public class StateSaverAndLoader extends PersistentState {
 
    // ... (Rest of code)
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
 
        NbtCompound playersNbt = new NbtCompound();
        players.forEach((uuid, playerData) -> {
            NbtCompound playerNbt = new NbtCompound();
 
            playerNbt.putInt("dirtBlocksBroken", playerData.dirtBlocksBroken);
 
            playersNbt.put(uuid.toString(), playerNbt);
        });
        nbt.put("players", playersNbt);
 
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag, RegistryWrapper.WrapperLookup registryLookup) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
 
        NbtCompound playersNbt = tag.getCompound("players");
        playersNbt.getKeys().forEach(key -> {
            PlayerData playerData = new PlayerData();
 
            playerData.dirtBlocksBroken = playersNbt.getCompound(key).getInt("dirtBlocksBroken");
 
            UUID uuid = UUID.fromString(key);
            state.players.put(uuid, playerData);
        });
 
        return state;
    }
 
    // ... (Rest of code)
 
}

The final StateSaverAndLoader should be as follows:

import net.minecraft.entity.LivingEntity;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
 
        NbtCompound playersNbt = new NbtCompound();
        players.forEach((uuid, playerData) -> {
            NbtCompound playerNbt = new NbtCompound();
 
            playerNbt.putInt("dirtBlocksBroken", playerData.dirtBlocksBroken);
 
            playersNbt.put(uuid.toString(), playerNbt);
        });
        nbt.put("players", playersNbt);
 
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag, RegistryWrapper.WrapperLookup registryLookup) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
 
        NbtCompound playersNbt = tag.getCompound("players");
        playersNbt.getKeys().forEach(key -> {
            PlayerData playerData = new PlayerData();
 
            playerData.dirtBlocksBroken = playersNbt.getCompound(key).getInt("dirtBlocksBroken");
 
            UUID uuid = UUID.fromString(key);
            state.players.put(uuid, playerData);
        });
 
        return state;
    }
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // If there's no 'StateSaverAndLoader' yet create one
            StateSaverAndLoader::createFromNbt, // If there is a 'StateSaverAndLoader' NBT, parse it with 'createFromNbt'
            null // Supposed to be an 'DataFixTypes' enum, but we can just pass null
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (Note: arbitrary choice to use 'World.OVERWORLD' instead of 'World.END' or 'World.NETHER'.  Any work)
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // The first time the following 'getOrCreate' function is called, it creates a brand new 'StateSaverAndLoader' and
        // stores it inside the 'PersistentStateManager'. The subsequent calls to 'getOrCreate' pass in the saved
        // 'StateSaverAndLoader' NBT on disk to our function 'StateSaverAndLoader::createFromNbt'.
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // If state is not marked dirty, when Minecraft closes, 'writeNbt' won't be called and therefore nothing will be saved.
        // Technically it's 'cleaner' if you only mark state as dirty when there was actually a change, but the vast majority
        // of mod writers are just going to be confused when their data isn't being saved, and so it's best just to 'markDirty' for them.
        // Besides, it's literally just setting a bool to true, and the only time there's a 'cost' is when the file is written to disk when
        // there were no actual change to any of the mods state (INCREDIBLY RARE).
        state.markDirty();
 
        return state;
    }
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // Either get the player by the uuid, or we don't have data for him yet, make a new player state
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}

Running the client now, all our player-specific data is correctly saved.

Important Caveat

  • Each time you restart the minecraft client with fabric, you're assigned a new random UUID each launch, so it may seem like our code is not working because it's pulling data for a new UUID never before seen. If you want to verify everything is working correctly, download AuthMe and drop the AuthMe.jar into run/mods. Login to your Minecraft account from the multiplayer screen. This will now change the random UUID to your actual UUID and therefore you can test if the data is correctly saved and associated with one UUID even across restarts. (You don't have to keep doing this, once you've verified once that, if the UUID is the same across launches, the data is correctly saved, you can just develop the mod normally safe in the knowledge that your data will be saved)

Just remember if you add new fields to PlayerData or StateSaveAndLoader you need to correctly do the work of writing and loading those fields in the writeNbt and createFromNbt functions always. If you forget this step, your data won't be properly saved or loaded from disk.

Initial Sync

What if it's important for our mod that as soon as a player joins they receive some or all the PlayerData associated with them? For this, we will crate a new packet INITIAL_SYNC which will send the player, their specific player data as soon as they join the world.

Modify your class which implements ModInitializer as follows:

import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "your_unique_mod_id_change_me_please";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    public static final Identifier INITIAL_SYNC = new Identifier(MOD_ID, "initial_sync");
 
    @Override
    public void onInitialize() {
        ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
            PlayerData playerState = StateSaverAndLoader.getPlayerState(handler.getPlayer());
            PacketByteBuf data = PacketByteBufs.create();
            data.writeInt(playerState.dirtBlocksBroken);
            server.execute(() -> {
                ServerPlayNetworking.send(handler.getPlayer(), INITIAL_SYNC, data);
            });
        });
 
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // Send a packet to the client
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
                data.writeInt(playerState.dirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}

Then modify your class which implements ClientModInitializer as follows:

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text;
 
public class ExampleModClient implements ClientModInitializer {
 
    public static PlayerData playerData = new PlayerData();
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Total dirt blocks broken: " + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("Player specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
 
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.INITIAL_SYNC, (client, handler, buf, responseSender) -> {
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Initial specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
    }
}

As soon as you join the world/server you should see a message popup telling you the amount of dirt blocks you've specifically broken.

  • Note: The playerData we created isn't the up-to-date one that lives on the server. We simply create our own copy of PlayerData client-side and update it as we receive packets. Since it's public static that means you can access it from anywhere on the client.

Important Caveat

  • Each time you restart the minecraft client with fabric, you're assigned a new random UUID each launch, so it may seem like our code is not working because it's pulling data for a new UUID never before seen. If you want to verify everything is working correctly, download AuthMe and drop the AuthMe.jar into run/mods. Login to your Minecraft account from the multiplayer screen. This will now change the random UUID to your actual UUID and therefore you can test if the data is correctly saved and associated with one UUID even across restarts. (You don't have to keep doing this, once you've verified once that, if the UUID is the same across launches, the data is correctly saved, you can just develop the mod normally safe in the knowledge that your data will be saved)

More Involved Player Data

And just for good measure, let's see an example of how our StateSaverAndLoader class would look if our PlayerData has more than primitives, like lists, and even its own hashmap. How would that look?

Let's say this is our PlayerData':

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
 
public class PlayerData {
    public int dirtBlocksBroken = 0;
 
    public HashMap<Integer, Integer> fatigue = new HashMap<>();
 
    public List<Integer> oldCravings = new ArrayList<>();
}

This would be our StateSaverAndLoader:

import net.minecraft.entity.LivingEntity;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.World;
 
import java.util.HashMap;
import java.util.UUID;
 
public class StateSaverAndLoader extends PersistentState {
 
    public Integer totalDirtBlocksBroken = 0;
 
    public HashMap<UUID, PlayerData> players = new HashMap<>();
 
    @Override
    public NbtCompound writeNbt(NbtCompound nbt) {
        nbt.putInt("totalDirtBlocksBroken", totalDirtBlocksBroken);
 
        NbtCompound playersNbt = new NbtCompound();
        players.forEach((uuid, playerData) -> {
            NbtCompound playerNbt = new NbtCompound();
 
            playerNbt.putInt("dirtBlocksBroken", playerData.dirtBlocksBroken);
 
            playerNbt.putIntArray("oldCravings", playerData.oldCravings);
 
            NbtCompound fatigueTag = new NbtCompound();
            playerData.fatigue.forEach((foodID, fatigueAmount) -> fatigueTag.putInt(String.valueOf(foodID), fatigueAmount));
            playerNbt.put("fatigue", fatigueTag);
 
            playersNbt.put(uuid.toString(), playerNbt);
        });
        nbt.put("players", playersNbt);
 
        return nbt;
    }
 
    public static StateSaverAndLoader createFromNbt(NbtCompound tag, RegistryWrapper.WrapperLookup registryLookup) {
        StateSaverAndLoader state = new StateSaverAndLoader();
        state.totalDirtBlocksBroken = tag.getInt("totalDirtBlocksBroken");
 
        NbtCompound playersNbt = tag.getCompound("players");
        playersNbt.getKeys().forEach(key -> {
            PlayerData playerData = new PlayerData();
 
            playerData.dirtBlocksBroken = playersNbt.getCompound(key).getInt("dirtBlocksBroken");
 
            NbtCompound fatigueCompound = playersNbt.getCompound(key).getCompound("fatigue");
            fatigueCompound.getKeys().forEach(s -> {
                Integer foodID = Integer.valueOf(s);
                int fatigueAmount = fatigueCompound.getInt(s);
                playerData.fatigue.put(foodID, fatigueAmount);
            });
 
            for (int oldCravings : playersNbt.getCompound(key).getIntArray("oldCravings")) {
                playerData.oldCravings.add(oldCravings);
            }
 
            UUID uuid = UUID.fromString(key);
            state.players.put(uuid, playerData);
        });
 
        return state;
    }
 
    private static Type<StateSaverAndLoader> type = new Type<>(
            StateSaverAndLoader::new, // If there's no 'StateSaverAndLoader' yet create one
            StateSaverAndLoader::createFromNbt, // If there is a 'StateSaverAndLoader' NBT, parse it with 'createFromNbt'
            null // Supposed to be an 'DataFixTypes' enum, but we can just pass null
    );
 
    public static StateSaverAndLoader getServerState(MinecraftServer server) {
        // (Note: arbitrary choice to use 'World.OVERWORLD' instead of 'World.END' or 'World.NETHER'.  Any work)
        PersistentStateManager persistentStateManager = server.getWorld(World.OVERWORLD).getPersistentStateManager();
 
        // The first time the following 'getOrCreate' function is called, it creates a brand new 'StateSaverAndLoader' and
        // stores it inside the 'PersistentStateManager'. The subsequent calls to 'getOrCreate' pass in the saved
        // 'StateSaverAndLoader' NBT on disk to our function 'StateSaverAndLoader::createFromNbt'.
        StateSaverAndLoader state = persistentStateManager.getOrCreate(type, ExampleMod.MOD_ID);
 
        // If state is not marked dirty, when Minecraft closes, 'writeNbt' won't be called and therefore nothing will be saved.
        // Technically it's 'cleaner' if you only mark state as dirty when there was actually a change, but the vast majority
        // of mod writers are just going to be confused when their data isn't being saved, and so it's best just to 'markDirty' for them.
        // Besides, it's literally just setting a bool to true, and the only time there's a 'cost' is when the file is written to disk when
        // there were no actual change to any of the mods state (INCREDIBLY RARE).
        state.markDirty();
 
        return state;
    }
 
    public static PlayerData getPlayerState(LivingEntity player) {
        StateSaverAndLoader serverState = getServerState(player.getWorld().getServer());
 
        // Either get the player by the uuid, or we don't have data for him yet, make a new player state
        PlayerData playerState = serverState.players.computeIfAbsent(player.getUuid(), uuid -> new PlayerData());
 
        return playerState;
    }
}

Our classes which implement ClientModInitializer and ModInitializer would be the same as before, but we include them here so that this section of the article is easy to copy and paste into you program and experiment with persistent state.

import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.text.Text;
 
public class ExampleModClient implements ClientModInitializer {
 
    public static PlayerData playerData = new PlayerData();
 
    @Override
    public void onInitializeClient() {
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.DIRT_BROKEN, (client, handler, buf, responseSender) -> {
            int totalDirtBlocksBroken = buf.readInt();
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Total dirt blocks broken: " + totalDirtBlocksBroken));
                client.player.sendMessage(Text.literal("Player specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
 
        ClientPlayNetworking.registerGlobalReceiver(ExampleMod.INITIAL_SYNC, (client, handler, buf, responseSender) -> {
            playerData.dirtBlocksBroken = buf.readInt();
 
            client.execute(() -> {
                client.player.sendMessage(Text.literal("Initial specific dirt blocks broken: " + playerData.dirtBlocksBroken));
            });
        });
    }
}
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.network.PacketByteBuf;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
 
public class ExampleMod implements ModInitializer {
 
    public static final String MOD_ID = "your_unique_mod_id_change_me_please";
 
    public static final Identifier DIRT_BROKEN = new Identifier(MOD_ID, "dirt_broken");
 
    public static final Identifier INITIAL_SYNC = new Identifier(MOD_ID, "initial_sync");
 
    @Override
    public void onInitialize() {
        ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
            PlayerData playerState = StateSaverAndLoader.getPlayerState(handler.getPlayer());
            PacketByteBuf data = PacketByteBufs.create();
            data.writeInt(playerState.dirtBlocksBroken);
            server.execute(() -> {
                ServerPlayNetworking.send(handler.getPlayer(), INITIAL_SYNC, data);
            });
        });
 
        PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {
            if (state.getBlock() == Blocks.GRASS_BLOCK || state.getBlock() == Blocks.DIRT) {
                StateSaverAndLoader serverState = StateSaverAndLoader.getServerState(world.getServer());
                // Increment the amount of dirt blocks that have been broken
                serverState.totalDirtBlocksBroken += 1;
 
                PlayerData playerState = StateSaverAndLoader.getPlayerState(player);
                playerState.dirtBlocksBroken += 1;
 
                // Send a packet to the client
                MinecraftServer server = world.getServer();
 
                PacketByteBuf data = PacketByteBufs.create();
                data.writeInt(serverState.totalDirtBlocksBroken);
                data.writeInt(playerState.dirtBlocksBroken);
 
                ServerPlayerEntity playerEntity = server.getPlayerManager().getPlayer(player.getUuid());
                server.execute(() -> {
                    ServerPlayNetworking.send(playerEntity, DIRT_BROKEN, data);
                });
            }
        });
    }
}
tutorial/persistent_states.txt · Last modified: 2024/04/25 14:06 by mayaqq