Persistent data
Item Constructors
You might assume that the baguette flamethrower's constructor is called every time the item is created - eg, every time we craft a baguette. But this is not the case.
The important thing to know is item constructors can be invoked arbitrarily, and not just when the item is 'created'.
Wait... why can constructors be called arbitrarily?
(sigh) It's complicated. To make a long story short...
Pylon can't keep track of every single item at all times. If we could actually modify the core server code, then this would be possible. Instead, Pylon only temporarily track Pylon items.
For example, when we right click an entity with the baguette flamethrower, Pylon listens to the PlayerInteractEntityEvent and sees that an entity has been right clicked with an item. In order to figure out what item it is - and if it's even a Pylon item at all - Pylon has to look at the key stored inside the item. But even if Pylon knows the key, it still don't know much about the item. Does the item implement PylonItemEntityInteractor? If so, Pylon needs to call its onUsedToRightClickEntity
.
This is where the constructor comes in. We can look up what class the item corresponds to - in this case BaguetteFlamethrower - and create a new instance of it. Then, we can call the onUsedToRightClickEntity
method.
What all this means is that the class can be created or destroyed at any time. Any data we store in fields is temporary.
But suppose we want to store the charge level of a portable battery. If we can't store the charge level in a field, where the hell can we store it?
Persistent data containers (PDCs)
PDCs are added by Spigot/Paper, not Pylon.
We are covering them here because they're used for essentially all persistent data storage (including for blocks and entities) in Pylon.
Persistent data containers (PDCs) are a way to persistently store arbitrary data on an item. You can think of them as a similar sort of thing to YAML. You can 'set' keys and you can 'get' keys, and the keys can have different kinds of data - like strings, ints, or even other PDCs.
Take our example of keeping track of the charge level of a portable battery. We can store the charge level in the charge_level
key in the item's PDC. It will then be saved when the item is put in a chest, or when the server restarts.
If this is all a bit confusing - don't worry, an example should make it clearer.
The Baguette of Wisdom
The idea
Studies have shown that those who eat baguettes have a 68% higher IQ on average. Let's use this as inspiration for our addon. We'll create a 'Baguette of Wisdom' which allows you to transfer experience from one player to another.
Here's the plan:
- The baguette has a maximum XP capacity
- You can right click with the baguette to 'charge' it with experience
- You can shift right click with the baguette to discharge the experience
To do this, we're going to need to keep track of how much experience the baguette has inside of it.
Creating the item
You know the drill from last time:
MyAddon.java |
---|
| NamespacedKey baguetteOfWisdomKey = new NamespacedKey(this, "baguette_of_wisdom");
ItemStack baguetteOfWisdom = ItemStackBuilder.pylonItem(Material.BREAD, baguetteOfWisdomKey)
.build();
PylonItem.register(BaguetteOfWisdom.class, baguetteOfWisdom);
BasePages.FOOD.addItem(baguetteOfWisdomKey);
|
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem {
public BaguetteOfWisdom(@NotNull ItemStack stack) {
super(stack);
}
}
|
en.yml |
---|
| item:
baguette_of_wisdom:
name: "Baguette of Wisdom"
lore: |-
<arrow> Use the power of baguettes to transfer XP
|
Adding a config for the XP capacity
Now, let's add a config value for the max XP capacity:
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem {
private final int xpCapacity = getSettings().getOrThrow("xp-capacity", Integer.class);
public BaguetteOfWisdom(@NotNull ItemStack stack) {
super(stack);
}
}
|
We'll be using this later.
Improving the lore
Let's add some instructions to the lore, and attributes for the max charge and current charge:
en.yml |
---|
| item:
baguette_of_wisdom:
name: "Baguette of Wisdom"
lore: |-
<arrow> Use the power of baguettes to transfer XP
<arrow> <insn>Right click</insn> to charge with XP
<arrow> <insn>Shift right click</insn> to discharge stored XP
<arrow> <attr>XP capacity:</attr> %xp_capacity%
<arrow> <attr>Stored XP:</attr> %stored_xp%
|
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem {
private final int xpCapacity = getSettings().getOrThrow("xp-capacity", Integer.class);
public BaguetteOfWisdom(@NotNull ItemStack stack) {
super(stack);
}
@Override
public @NotNull List<PylonArgument> getPlaceholders() {
return List.of(
PylonArgument.of("xp_capacity", UnitFormat.EXPERIENCE.format(xpCapacity))
// TODO add stored_xp placeholder
);
}
}
|
All of this should be familiar from the advanced lore section.
The charge/discharge mechanic
Next, let's allow the player to charge by right clicking, and discharge by shift right clicking.
We can use the PylonInteractor class to do this:
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem implements PylonInteractor {
private final int xpCapacity = getSettings().getOrThrow("xp-capacity", Integer.class);
public BaguetteOfWisdom(@NotNull ItemStack stack) {
super(stack);
}
@Override
public @NotNull List<PylonArgument> getPlaceholders() {
return List.of(
PylonArgument.of("xp_capacity", UnitFormat.EXPERIENCE.format(xpCapacity))
// TODO add stored_xp placeholder
);
}
@Override
public void onUsedToRightClick(@NotNull PlayerInteractEvent event) {
if (event.getPlayer().isSneaking()) {
// TODO discharge logic
} else {
// TODO charge logic
}
}
}
|
The charge logic
Let's now do the charge logic. In order to charge a Baguette of Wisdom, we need to store its charge level. As mentioned beforehand, we can use the item's persistent data container to do this. To start with, let's just set the charge level to 50:
BaguetteOfWisdom.java |
---|
| @Override
public void onUsedToRightClick(@NotNull PlayerInteractEvent event) {
if (event.getPlayer().isSneaking()) {
// TODO discharge logic
} else {
getStack().editPersistentDataContainer(pdc -> pdc.set(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER,
50
));
}
}
|
As you can see, we need to provide three things to set a PDC value: the key, the serializer, and the value.
The serializer is just a 'thing' that describes how to convert your type into a more primitive type that can be stored on disk - we won't go into details. You need a serializer for every type that you want to store - so you can't store, for example, MyAddon
in a persistent data container as there is no serializer for it and it doesn't make sense to create one anyway.
You can find a full list of serializers here
Ok. But what we really need to do is 'top up' the stored xp using the player's experience:
- Read how much XP we already have stored
- Figure out how much XP we need to take to get to
xpCapacity
- Take as much XP from the player as we can to get there
- Set the new XP amount
BaguetteOfWisdom.java |
---|
| @Override
public void onUsedToRightClick(@NotNull PlayerInteractEvent event) {
if (event.getPlayer().isSneaking()) {
// TODO discharge logic
} else {
// 1. Read how much XP we already have stored
int xp = getStack().getPersistentDataContainer().get(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER
);
// 2. Figure out how much XP we need to take to get to `xpCapacity`
int extraXpNeeded = xpCapacity - xp;
// 3. Take as much XP from the player as we can to get there
int xpToTake = Math.min(event.getPlayer().calculateTotalExperiencePoints(), extraXpNeeded);
event.getPlayer().giveExp(-xpToTake);
// 4. Set the new stored XP amount
getStack().editPersistentDataContainer(pdc -> pdc.set(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER,
xp + xpToTake
));
}
}
|
The discharge logic
And now for the discharge logic, which is quite similar:
BaguetteOfWisdom.java |
---|
| @Override
public void onUsedToRightClick(@NotNull PlayerInteractEvent event) {
if (event.getPlayer().isSneaking()) {
// 1. Read how much XP we have stored
int xp = getStack().getPersistentDataContainer().get(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER
);
// 2. Give all the XP to the player
event.getPlayer().giveExp(xp);
// 3. Set the stored XP to 0
getStack().editPersistentDataContainer(pdc -> pdc.set(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER,
0
));
} else {
...
}
}
|
Adding a placeholder
Finally, let's add in the placeholder for the stored charge:
BaguetteOfWisdom.java |
---|
| @Override
public @NotNull List<PylonArgument> getPlaceholders() {
return List.of(
PylonArgument.of("xp_capacity", UnitFormat.EXPERIENCE.format(xpCapacity)),
PylonArgument.of("stored_xp", UnitFormat.EXPERIENCE.format(
getStack().getPersistentDataContainer().get(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER
))
)
);
}
|
Testing it out
Now let's start the server and test our glorious new item. Try giving yourself a Baguette of Wisdom.
Obviously, as I am an expert programmer, it will work perfectly the first time and nothing will go wro- huh?

What nonsense is this? Have the brits sabotaged us again?!
When something like this happens, your first port of call should always be the server console to see if any errors have been logged. And indeed, if you have a look in the console you should find the following error:
console |
---|
| [12:51:22 WARN]: java.lang.NullPointerException: Cannot invoke "java.lang.Float.floatValue()" because the return value of "io.papermc.paper.persistence.PersistentDataContainerView.get(org.bukkit.NamespacedKey, org.bukkit.persistence.PersistentDataType)" is null
[12:51:22 WARN]: at my-addon-MODIFIED-1757418660070.jar//io.github.pylonmc.myaddon.BaguetteOfWisdom.getPlaceholders(BaguetteOfWisdom.java:28)
[12:51:22 WARN]: at pylon-core-0.11.2.jar//io.github.pylonmc.pylon.core.guide.button.ItemButton.getItemProvider(ItemButton.kt:47)
[12:51:22 WARN]: at xyz.xenondevs.invui.gui.SlotElement$ItemSlotElement.getItemStack(SlotElement.java:44)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow.redrawItem(AbstractWindow.java:109)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractSingleWindow.initItems(AbstractSingleWindow.java:58)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow.open(AbstractWindow.java:279)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow$AbstractBuilder.open(AbstractWindow.java:679)
[12:51:22 WARN]: at pylon-core-0.11.2.jar//io.github.pylonmc.pylon.core.guide.pages.base.GuidePage.open(GuidePage.kt:28)
[12:51:22 WARN]: at pylon-core-0.11.2.jar//io.github.pylonmc.pylon.core.guide.button.PageButton.handleClick(PageButton.kt:38)
[12:51:22 WARN]: at xyz.xenondevs.invui.gui.AbstractGui.handleClick(AbstractGui.java:95)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractSingleWindow.handleClick(AbstractSingleWindow.java:84)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.AbstractWindow.handleClickEvent(AbstractWindow.java:199)
[12:51:22 WARN]: at xyz.xenondevs.invui.window.WindowManager.handleInventoryClick(WindowManager.java:117)
[12:51:22 WARN]: at co.aikar.timings.TimedEventExecutor.execute(TimedEventExecutor.java:80)
[12:51:22 WARN]: at org.bukkit.plugin.RegisteredListener.callEvent(RegisteredListener.java:71)
[12:51:22 WARN]: at io.papermc.paper.plugin.manager.PaperEventManager.callEvent(PaperEventManager.java:54)
[12:51:22 WARN]: at io.papermc.paper.plugin.manager.PaperPluginManagerImpl.callEvent(PaperPluginManagerImpl.java:131)
[12:51:22 WARN]: at org.bukkit.plugin.SimplePluginManager.callEvent(SimplePluginManager.java:628)
[12:51:22 WARN]: at net.minecraft.server.network.ServerGamePacketListenerImpl.handleContainerClick(ServerGamePacketListenerImpl.java:3208)
[12:51:22 WARN]: at net.minecraft.network.protocol.game.ServerboundContainerClickPacket.handle(ServerboundContainerClickPacket.java:59)
[12:51:22 WARN]: at net.minecraft.network.protocol.game.ServerboundContainerClickPacket.handle(ServerboundContainerClickPacket.java:14)
[12:51:22 WARN]: at net.minecraft.network.protocol.PacketUtils.lambda$ensureRunningOnSameThread$0(PacketUtils.java:29)
[12:51:22 WARN]: at net.minecraft.server.TickTask.run(TickTask.java:18)
[12:51:22 WARN]: at net.minecraft.util.thread.BlockableEventLoop.doRunTask(BlockableEventLoop.java:155)
[12:51:22 WARN]: at net.minecraft.util.thread.ReentrantBlockableEventLoop.doRunTask(ReentrantBlockableEventLoop.java:24)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.doRunTask(MinecraftServer.java:1450)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.doRunTask(MinecraftServer.java:176)
[12:51:22 WARN]: at net.minecraft.util.thread.BlockableEventLoop.pollTask(BlockableEventLoop.java:129)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.pollTaskInternal(MinecraftServer.java:1430)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.pollTask(MinecraftServer.java:1424)
[12:51:22 WARN]: at net.minecraft.util.thread.BlockableEventLoop.managedBlock(BlockableEventLoop.java:139)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.managedBlock(MinecraftServer.java:1381)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.waitUntilNextTick(MinecraftServer.java:1389)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.runServer(MinecraftServer.java:1266)
[12:51:22 WARN]: at net.minecraft.server.MinecraftServer.lambda$spin$2(MinecraftServer.java:310)
|
Wow. That's a fat error. But the lines we are most interested in are right at the top:
console |
---|
| [12:51:22 WARN]: java.lang.NullPointerException: Cannot invoke "java.lang.Float.floatValue()" because the return value of "io.papermc.paper.persistence.PersistentDataContainerView.get(org.bukkit.NamespacedKey, org.bukkit.persistence.PersistentDataType)" is null
[12:51:22 WARN]: at my-addon-MODIFIED-1757418660070.jar//io.github.pylonmc.myaddon.BaguetteOfWisdom.getPlaceholders(BaguetteOfWisdom.java:28)
|
So it looks like the error occurred on line 28, in the getPlaceholders
function, where we try to read from the persistent data container. Apparently, the 'stored_xp' key couldn't be found in the PDC, because the call to getStack().getPersistentDataContainer().get(...)
returned null.
Wait, why did getPlaceholders
get called and error now? We haven't given ourselves the item yet.
Simply put, the guide also needs to call getPlaceholders
to display the item to you. The error only appears once you open the guide - or once you give yourself the item with /py give
.
This actually makes perfect sense if you think about it. At no point do we set a default value for the stored XP, so of course any call to get it will return null.
Adding a default value
To add a default value for stored XP to the PDC, we can modify the itemstack itself when we create it:
MyAddon.java |
---|
| NamespacedKey baguetteOfWisdomKey = new NamespacedKey(this, "baguette_of_wisdom");
ItemStack baguetteOfWisdom = ItemStackBuilder.pylonItem(Material.BREAD, baguetteOfWisdomKey)
.editPdc(pdc -> pdc.set(
new NamespacedKey(this, "stored_xp"),
PylonSerializers.INTEGER,
0
))
.build();
PylonItem.register(BaguetteOfWisdom.class, baguetteOfWisdom);
BasePages.FOOD.addItem(baguetteOfWisdomKey);
|
Now let's try again.

Ah, perfect!
One last thing left to do...
Cleaning up
The Baguette of Wisdom works, but there are some improvements we can make.
First, we could pull out the get/set code into their own functions:
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem implements PylonInteractor {
...
public void setStoredXp(int xp) {
getStack().editPersistentDataContainer(pdc -> pdc.set(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER,
xp
));
}
public int getStoredXp() {
return getStack().getPersistentDataContainer().get(
new NamespacedKey(MyAddon.getInstance(), "stored_xp"),
PylonSerializers.INTEGER
);
}
}
|
And now, we can use these functions in the rest of the code, which is much cleaner:
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem implements PylonInteractor {
...
@Override
public @NotNull List<PylonArgument> getPlaceholders() {
return List.of(
PylonArgument.of("xp_capacity", UnitFormat.EXPERIENCE.format(xpCapacity)),
PylonArgument.of("stored_xp", UnitFormat.EXPERIENCE.format(getStoredXp()))
);
}
@Override
public void onUsedToRightClick(@NotNull PlayerInteractEvent event) {
if (event.getPlayer().isSneaking()) {
int xp = getStoredXp();
// 2. Give all the XP to the player
event.getPlayer().giveExp(xp);
// 3. Set the stored XP to 0
setStoredXp(0);
} else {
// 1. Read how much XP we already have stored
int xp = getStoredXp();
// 2. Figure out how much XP we need to take to get to `xpCapacity`
int extraXpNeeded = xpCapacity - xp;
// 3. Take as much XP from the player as we can to get there
int xpToTake = Math.min(event.getPlayer().calculateTotalExperiencePoints(), extraXpNeeded);
event.getPlayer().giveExp(-xpToTake);
// 4. Set the new stored XP amount
setStoredXp(xp + xpToTake);
}
}
...
}
|
The second thing we should do is reuse NamespacedKeys. This is more of a 'best practice' thing - it's generally recommend to reuse keys. It'll become more apparent why later on.
BaguetteOfWisdom.java |
---|
| public class BaguetteOfWisdom extends PylonItem implements PylonInteractor {
public static final NamespacedKey STORED_XP_KEY = new NamespacedKey(MyAddon.getInstance(), "stored_xp");
...
public void setStoredXp(int xp) {
getStack().editPersistentDataContainer(pdc -> pdc.set(
STORED_XP_KEY,
PylonSerializers.INTEGER,
xp
));
}
public int getStoredXp() {
return getStack().getPersistentDataContainer().get(
STORED_XP_KEY,
PylonSerializers.INTEGER
);
}
|
MyAddon.java |
---|
| ItemStack baguetteOfWisdom = ItemStackBuilder.pylonItem(Material.BREAD, baguetteOfWisdomKey)
.editPdc(pdc -> pdc.set(
BaguetteOfWisdom.STORED_XP_KEY,
PylonSerializers.INTEGER,
0
))
.build();
|
And that's it!