Creating a Custom Adapter
This guide walks through building a custom SnapshotAdapter that saves and restores plugin-specific player data. The example tracks kill and death counts.
Step 1 — Create the Data Class
Define a plain Java class to hold the data you want to persist.
public class PlayerStats {
private int kills;
private int deaths;
public PlayerStats() {}
public PlayerStats(int kills, int deaths) {
this.kills = kills;
this.deaths = deaths;
}
public int getKills() { return kills; }
public int getDeaths() { return deaths; }
public void setKills(int kills) { this.kills = kills; }
public void setDeaths(int deaths) { this.deaths = deaths; }
}
Step 2 — Implement SnapshotAdapter
Implement all six methods of SnapshotAdapter<T>.
import com.astralrealms.sync.adapter.SnapshotAdapter;
import com.astralrealms.sync.model.holder.DataHolder;
import net.kyori.adventure.key.Key;
import org.bukkit.entity.Player;
public class PlayerStatsAdapter implements SnapshotAdapter<PlayerStats> {
private static final Key KEY = Key.key("myplugin", "stats");
/**
* Unique identifier for this adapter.
* Use your plugin's name as the namespace.
*/
@Override
public Key key() {
return KEY;
}
/**
* Called for brand-new players who have no snapshot yet.
* Return sensible defaults.
*/
@Override
public PlayerStats create(Player player) {
return new PlayerStats(0, 0);
}
/**
* Called just before serialization to capture current state.
* Update the object in-place and return it.
*/
@Override
public PlayerStats update(Player player, PlayerStats stats) {
// If stats are stored in memory elsewhere (e.g. a local cache),
// pull them in here. Otherwise just return the object as-is.
return stats;
}
/**
* Called on the main server thread after deserialization.
* Apply the loaded data to the player.
*/
@Override
public void apply(Player player, PlayerStats stats) {
// Example: store in a local cache so your plugin can read it quickly
StatsCache.put(player.getUniqueId(), stats);
}
/**
* Read the data object from the binary stream.
* Order must match serialize().
*/
@Override
public PlayerStats deserialize(DataHolder holder, BinaryMessage message) {
int kills = message.readInt();
int deaths = message.readInt();
return new PlayerStats(kills, deaths);
}
/**
* Write the data object to the binary stream.
* Order must match deserialize().
*/
@Override
public void serialize(DataHolder holder, PlayerStats stats, BinaryMessage message) {
message.writeInt(stats.getKills());
message.writeInt(stats.getDeaths());
}
}
Step 3 — Register in onEnable()
Register the adapter in your plugin's onEnable(). This must happen before any player joins.
import com.astralrealms.sync.SyncAPI;
public class MyPlugin extends JavaPlugin {
@Override
public void onEnable() {
SyncAPI.registerAdapter(new PlayerStatsAdapter());
}
}
Step 4 — Access Data at Runtime
Read the data using SyncAPI. Safe to call after PlayerDataLoadedEvent fires.
@EventHandler
public void onDataLoaded(PlayerDataLoadedEvent event) {
Player player = event.getPlayer();
SyncAPI.findData(player.getUniqueId(), PlayerStats.class).ifPresent(stats -> {
player.sendMessage("Kills: " + stats.getKills() + " | Deaths: " + stats.getDeaths());
});
}
Or by key:
Key key = Key.key("myplugin", "stats");
SyncAPI.<PlayerStats>findData(player.getUniqueId(), key).ifPresent(stats -> { ... });
Optional: Adding Versioning
If your data format may change in a future plugin update, implement VersionedSnapshotAdapter instead. This lets you add a v2 while still reading old v1 snapshots from the database.
Your data class must implement VersionedData:
import com.astralrealms.sync.model.serializer.VersionedData;
public class PlayerStatsV2 implements VersionedData {
private int kills;
private int deaths;
private long playtimeSeconds; // new field in v2
@Override
public int version() {
return 2;
}
// getters / setters ...
}
The adapter declares its version:
import com.astralrealms.sync.adapter.VersionedSnapshotAdapter;
public class PlayerStatsAdapterV2 implements VersionedSnapshotAdapter<PlayerStatsV2> {
@Override
public int version() {
return 2;
}
@Override
public Key key() {
return Key.key("myplugin", "stats");
}
@Override
public PlayerStatsV2 deserialize(DataHolder holder, BinaryMessage message) {
int kills = message.readInt();
int deaths = message.readInt();
long playtime = message.readLong(); // new in v2
return new PlayerStatsV2(kills, deaths, playtime);
}
@Override
public void serialize(DataHolder holder, PlayerStatsV2 stats, BinaryMessage message) {
message.writeInt(stats.getKills());
message.writeInt(stats.getDeaths());
message.writeLong(stats.getPlaytimeSeconds());
}
// ... create(), update(), apply()
}
Register both adapters so that old v1 snapshots can still be read:
@Override
public void onEnable() {
SyncAPI.registerAdapter(new PlayerStatsAdapterV1()); // reads old snapshots
SyncAPI.registerAdapter(new PlayerStatsAdapterV2()); // used for all new saves
}
Optional: Cleanup on Unload
If your adapter allocates resources that must be released when a player disconnects, implement UnloadableSnapshotAdapter:
import com.astralrealms.sync.adapter.UnloadableSnapshotAdapter;
public class PlayerStatsAdapter implements UnloadableSnapshotAdapter<PlayerStats> {
// ... SnapshotAdapter methods ...
/**
* Called automatically when SnapshotCause.shouldUnload() == true
* (i.e. DISCONNECT or SERVER_SHUTDOWN).
*/
@Override
public void unload(DataHolder holder, Player player, PlayerStats stats) {
StatsCache.remove(player.getUniqueId());
}
}
You do not need to listen to quit events or call unload() yourself — AstralSync invokes it at the correct time.
Checklist
[ ] Data class has a no-arg constructor (or is constructed inside create())
[ ] key() uses your plugin's name as the namespace (not astralsync)
[ ] serialize() and deserialize() write/read fields in the same order
[ ] Adapter is registered in onEnable() before any player joins
[ ] Data is read only after PlayerDataLoadedEvent fires or hasFinishedLoading() is true
[ ] If using versioning, all old adapters remain registered alongside the new one
24 April 2026