Guardar y cargar el estado del juego con IndexedDB
IndexedDB es la mejor opción para almacenar datos persistentes de juegos en el navegador. Gestiona grandes cantidades de datos, funciona sin conexión y no bloquea el hilo principal.
1) ¿Por qué usar IndexedDB en lugar de localStorage?
| Característica | localStorage | IndexedDB |
|---|---|---|
| Límite de almacenamiento | ~5-10 MB | 50+ MB (a menudo, GB) |
| Tipos de datos | Solo cadenas | Objetos, blobs y arrays |
| Asíncrono | No (bloquea) | Sí |
| Consultas indexadas | No | Sí |
Para los juegos, IndexedDB casi siempre es la opción adecuada.
2) Abrir una base de datos
function openGameDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyGame', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
// Create stores
if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'slot' })
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
}
})
}3) Guardar el estado del juego
async function saveGame(slot, gameState) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readwrite')
const store = tx.objectStore('saves')
const saveData = {
slot,
state: gameState,
timestamp: Date.now(),
}
const request = store.put(saveData)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}4) Cargar el estado del juego
async function loadGame(slot) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readonly')
const store = tx.objectStore('saves')
const request = store.get(slot)
request.onsuccess = () => resolve(request.result?.state || null)
request.onerror = () => reject(request.error)
})
}5) Enumerar todas las partidas guardadas
async function listSaves() {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readonly')
const store = tx.objectStore('saves')
const request = store.getAll()
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}6) Eliminar una partida guardada
async function deleteSave(slot) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readwrite')
const store = tx.objectStore('saves')
const request = store.delete(slot)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}7) Una clase GameStorage completa
class GameStorage {
constructor(dbName = 'GameData', version = 1) {
this.dbName = dbName
this.version = version
this.db = null
}
async init() {
this.db = await this.openDB()
}
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (e) => {
const db = e.target.result
if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'slot' })
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
if (!db.objectStoreNames.contains('assets')) {
db.createObjectStore('assets', { keyPath: 'url' })
}
}
})
}
async save(slot, data) {
const tx = this.db.transaction('saves', 'readwrite')
tx.objectStore('saves').put({ slot, data, timestamp: Date.now() })
return tx.complete
}
async load(slot) {
const tx = this.db.transaction('saves', 'readonly')
const result = await this.promisify(tx.objectStore('saves').get(slot))
return result?.data || null
}
async setSetting(key, value) {
const tx = this.db.transaction('settings', 'readwrite')
tx.objectStore('settings').put({ key, value })
}
async getSetting(key, defaultValue = null) {
const tx = this.db.transaction('settings', 'readonly')
const result = await this.promisify(tx.objectStore('settings').get(key))
return result?.value ?? defaultValue
}
promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
}8) Almacenar datos binarios (texturas y audio)
IndexedDB admite Blobs y ArrayBuffers:
async function cacheAsset(url, blob) {
const tx = db.transaction('assets', 'readwrite')
tx.objectStore('assets').put({ url, blob, cached: Date.now() })
}
async function getCachedAsset(url) {
const tx = db.transaction('assets', 'readonly')
const result = await promisify(tx.objectStore('assets').get(url))
return result?.blob || null
}9) Patrón de guardado automático
class AutoSave {
constructor(storage, interval = 60000) {
this.storage = storage
this.interval = interval
this.timer = null
this.dirty = false
}
markDirty() {
this.dirty = true
}
start(getState) {
this.timer = setInterval(async () => {
if (this.dirty) {
await this.storage.save('autosave', getState())
this.dirty = false
console.log('Autosaved')
}
}, this.interval)
}
stop() {
clearInterval(this.timer)
}
}10) Gestión de errores y alternativas
async function safeLoad(slot, defaultState) {
try {
const saved = await loadGame(slot)
if (saved) {
// Validate/migrate old saves if needed
return migrateSave(saved)
}
} catch (err) {
console.warn('Failed to load save:', err)
}
return defaultState
}
function migrateSave(save) {
// Handle old save formats
if (!save.version) {
save.version = 1
save.settings = save.settings || {}
}
return save
}11) Evitar que se eliminen las partidas guardadas
No se garantiza que los datos de IndexedDB permanezcan indefinidamente. De forma predeterminada, un origen utiliza almacenamiento de «mejor esfuerzo», y el navegador puede eliminarlo cuando el disco se llena o, en Safari/WebKit, después de un periodo sin interacción del usuario con tu sitio. En el caso de las partidas guardadas, esos son precisamente los datos que no quieres perder.
Solicita almacenamiento persistente para que el navegador no borre tus datos sin una acción explícita del usuario:
async function makeStoragePersistent() {
if (navigator.storage && navigator.storage.persist) {
const persisted = await navigator.storage.persist()
console.log(persisted ? 'Saves are protected from eviction' : 'Saves may be evicted under storage pressure')
return persisted
}
return false
}Los navegadores deciden si lo conceden en función de señales de interacción, como cuánto interactúa el usuario con tu sitio o si está instalado como PWA, así que no des por hecho que siempre funcionará. También puedes comprobar cuánto espacio tienes disponible antes de escribir partidas guardadas grandes o recursos en caché:
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate()
console.log(`Using ${usage} of ${quota} bytes`)
}
}Ambas API necesitan un contexto seguro (HTTPS o localhost).
Contenido relacionado
- PWA para juegos sin conexión
- Service workers para almacenar juegos en caché
- Publica un juego web que cargue rápido
- Carga de recursos por streaming — uso de IndexedDB como caché de recursos
- Analítica para juegos web — seguimiento de patrones de guardado y carga para comprender el comportamiento de los jugadores
Recursos externos
- MDN: API de IndexedDB — referencia completa de la API
- MDN: Uso de IndexedDB — guía paso a paso
- Biblioteca idb — un pequeño contenedor de IndexedDB basado en promesas