Helps save and load persistent data in HYTOPIA SDK games. Use when users need to save player progress, leaderboards, game state, or any data that persists across sessions. Covers PersistenceManager, global data, and player data.
Install
npx skillscat add abstrucked/hytopia-skills/hytopia-persisted-data Install via the SkillsCat registry.
HYTOPIA Persisted Data
This skill helps you save and load persistent data in HYTOPIA SDK games.
Documentation: https://dev.hytopia.com/sdk-guides/persisted-data
When to Use This Skill
Use this skill when the user:
- Wants to save player progress between sessions
- Needs to create leaderboards or high scores
- Asks about storing game configuration
- Wants persistent inventories or unlocks
- Needs to save world state
- Asks about data persistence across server restarts
Data Types
| Type | Scope | Use Cases |
|---|---|---|
| Global Data | All game instances | Leaderboards, game config, shared state |
| Player Data | Per player | Progress, inventory, stats, preferences |
Global Data
Shared across all running game instances.
Set Global Data
import { PersistenceManager } from 'hytopia';
// Save global data
await PersistenceManager.instance.setGlobalData('leaderboard', [
{ name: 'Player1', score: 1000 },
{ name: 'Player2', score: 950 },
{ name: 'Player3', score: 900 }
]);
// Save game config
await PersistenceManager.instance.setGlobalData('game-config', {
maxPlayers: 20,
roundDuration: 300,
difficulty: 'hard'
});Get Global Data
import { PersistenceManager } from 'hytopia';
// Get leaderboard
const leaderboard = await PersistenceManager.instance.getGlobalData('leaderboard');
console.log('Top scores:', leaderboard);
// Get with default
const config = await PersistenceManager.instance.getGlobalData('game-config');
if (!config) {
// Use defaults
}Player Data
Persisted per player across sessions.
Set Player Data
import { Player } from 'hytopia';
// Save player progress
await player.setPersistedData('level', 15);
await player.setPersistedData('xp', 2500);
await player.setPersistedData('gold', 1000);
// Save complex data
await player.setPersistedData('inventory', [
{ id: 'sword', quantity: 1 },
{ id: 'potion', quantity: 5 },
{ id: 'key', quantity: 3 }
]);
await player.setPersistedData('unlocks', {
skins: ['default', 'warrior', 'mage'],
maps: ['forest', 'desert'],
achievements: ['first-kill', 'speedrun']
});Get Player Data
import { Player } from 'hytopia';
// Get player progress
const level = await player.getPersistedData('level') || 1;
const xp = await player.getPersistedData('xp') || 0;
const gold = await player.getPersistedData('gold') || 100;
// Get inventory
const inventory = await player.getPersistedData('inventory') || [];
// Load player on join
world.onPlayerJoin = async (player) => {
const savedData = await player.getPersistedData('progress');
if (savedData) {
player.setData('level', savedData.level);
player.setData('xp', savedData.xp);
player.setHealth(savedData.health);
console.log(`Loaded ${player.username}'s progress`);
} else {
// New player - set defaults
player.setData('level', 1);
player.setData('xp', 0);
console.log(`New player: ${player.username}`);
}
};Shallow Merging
When updating object data, HYTOPIA performs shallow merging at the root level.
// Initial data
await player.setPersistedData('stats', {
kills: 10,
deaths: 5,
playtime: 3600
});
// Update only kills - other fields preserved
await player.setPersistedData('stats', {
kills: 15
});
// Result: { kills: 15, deaths: 5, playtime: 3600 }
// WARNING: Nested objects are replaced entirely
await player.setPersistedData('settings', {
audio: { music: 0.5, sfx: 1.0 },
video: { quality: 'high' }
});
await player.setPersistedData('settings', {
audio: { music: 0.3 } // sfx is LOST!
});
// Result: { audio: { music: 0.3 }, video: { quality: 'high' } }Safe Nested Updates
// Always fetch, modify, and save for nested data
async function updateNestedSetting(player: Player, path: string, value: any) {
const settings = await player.getPersistedData('settings') || {};
// Deep update
const keys = path.split('.');
let obj = settings;
for (let i = 0; i < keys.length - 1; i++) {
obj[keys[i]] = obj[keys[i]] || {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
await player.setPersistedData('settings', settings);
}
// Usage
await updateNestedSetting(player, 'audio.music', 0.3);Common Patterns
Leaderboard System
import { PersistenceManager } from 'hytopia';
async function updateLeaderboard(playerName: string, score: number) {
// Get current leaderboard
const leaderboard = await PersistenceManager.instance.getGlobalData('leaderboard') || [];
// Check if player already on board
const existingIndex = leaderboard.findIndex(e => e.name === playerName);
if (existingIndex !== -1) {
// Update if new score is higher
if (score > leaderboard[existingIndex].score) {
leaderboard[existingIndex].score = score;
}
} else {
// Add new entry
leaderboard.push({ name: playerName, score });
}
// Sort and keep top 100
leaderboard.sort((a, b) => b.score - a.score);
const top100 = leaderboard.slice(0, 100);
await PersistenceManager.instance.setGlobalData('leaderboard', top100);
return top100;
}
async function getLeaderboard(limit: number = 10) {
const leaderboard = await PersistenceManager.instance.getGlobalData('leaderboard') || [];
return leaderboard.slice(0, limit);
}Auto-Save System
class AutoSave {
private saveInterval: number = 60000; // 1 minute
private dirty: Set<string> = new Set();
constructor() {
setInterval(() => this.saveAll(), this.saveInterval);
}
markDirty(playerId: string) {
this.dirty.add(playerId);
}
async saveAll() {
for (const playerId of this.dirty) {
const player = world.getPlayer(playerId);
if (player) {
await this.savePlayer(player);
}
}
this.dirty.clear();
console.log(`Auto-saved ${this.dirty.size} players`);
}
async savePlayer(player: Player) {
await player.setPersistedData('progress', {
level: player.getData('level'),
xp: player.getData('xp'),
gold: player.getData('gold'),
inventory: player.getData('inventory'),
lastSaved: Date.now()
});
}
}
const autoSave = new AutoSave();
// Mark player dirty when they change
function addXP(player: Player, amount: number) {
const currentXP = player.getData('xp') || 0;
player.setData('xp', currentXP + amount);
autoSave.markDirty(player.id);
}Save on Disconnect
world.onPlayerLeave = async (player) => {
// Save all player data before they leave
await player.setPersistedData('progress', {
level: player.getData('level'),
xp: player.getData('xp'),
position: player.position,
inventory: player.getData('inventory'),
lastPlayed: Date.now()
});
console.log(`Saved ${player.username}'s progress`);
};Unlockables System
async function unlockItem(player: Player, category: string, itemId: string) {
const unlocks = await player.getPersistedData('unlocks') || {};
if (!unlocks[category]) {
unlocks[category] = [];
}
if (!unlocks[category].includes(itemId)) {
unlocks[category].push(itemId);
await player.setPersistedData('unlocks', unlocks);
player.sendMessage(`Unlocked: ${itemId}!`);
return true;
}
return false; // Already unlocked
}
async function hasUnlock(player: Player, category: string, itemId: string) {
const unlocks = await player.getPersistedData('unlocks') || {};
return unlocks[category]?.includes(itemId) || false;
}
// Usage
await unlockItem(player, 'skins', 'golden-armor');
const hasSkin = await hasUnlock(player, 'skins', 'golden-armor');Environment Configuration
Local Development
Data persists in auto-generated dev/ directory between restarts.
Tip: Use single browser tabs during local testing - player IDs are assigned sequentially starting at 1 after server restarts.
Production
Set environment variables:
NODE_ENV=productionHYTOPIA_API_KEY- Your API keyHYTOPIA_GAME_ID- Your game IDHYTOPIA_LOBBY_ID- Your lobby ID
Upon deployment, HYTOPIA automatically configures persistence services.
Best Practices
- Save on important actions - Don't wait for disconnect
- Use auto-save - Backup every few minutes
- Handle missing data - Always provide defaults
- Validate loaded data - Check for corruption/outdated formats
- Batch updates - Don't save on every tiny change
- Version your data - Include version number for migrations
- Test with fresh data - Delete dev/ folder to test new player flow
Data Migration Example
const CURRENT_VERSION = 2;
async function loadPlayerData(player: Player) {
const data = await player.getPersistedData('progress');
if (!data) {
return getDefaultData();
}
// Migrate old data formats
if (!data.version || data.version < CURRENT_VERSION) {
const migrated = migrateData(data);
await player.setPersistedData('progress', migrated);
return migrated;
}
return data;
}
function migrateData(data: any) {
let migrated = { ...data };
// v1 -> v2: inventory format changed
if (!data.version || data.version < 2) {
if (Array.isArray(data.inventory)) {
migrated.inventory = data.inventory.map(item =>
typeof item === 'string'
? { id: item, quantity: 1 }
: item
);
}
}
migrated.version = CURRENT_VERSION;
return migrated;
}Common Mistakes
- Not handling null/undefined when data doesn't exist
- Saving too frequently (causes performance issues)
- Not saving before player disconnects
- Forgetting shallow merge behavior for nested objects
- Not testing with fresh player data