Table of Contents
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; TheServerPlayerEntity
. AnIdentifer
which is needed when sending a packet between server and client or client and server, and aPacketByteBuf
which we are able to fill with arbritary data (that is: bools, ints, arrays, strings, and more). ThePacketByteBuf
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 thenbuf.readInt()
is used to extract the data that was packed into thePackedByteBuf
. 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 storeIntegers
. It has functions for strings, arrays, bools, floats, importantly otherNbtCompound
's as you'll see soon enough, and even arbitrary bytes. That means, if you want to store someSuperCustomClass
, what you should do is create anew NbtCompound
and pack that newNbtCompound
with the fields of yourSuperCustomClass
and then store it in the mainNbtCompound
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 inwriteNbt
.
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 somePlayerData
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 anINITIAL_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 theUUID
before then it makes sense that ourHashMap
won't return anything. That's why we use the convenience functioncomputeIfAbsent
so that in the case where thatUUID
doesn't exist in the hashmap yet, it will automatically create a newPlayerData
and puts it in our hashmap, using that players specificUUID
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 ofPlayerData
client-side and update it as we receive packets. Since it'spublic 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); }); } }); } }