Spielstand mit IndexedDB speichern und laden
IndexedDB ist die beste Option für dauerhafte Spieldaten im Browser. Es kommt mit großen Datenmengen zurecht, funktioniert offline und blockiert den Haupt-Thread nicht.
1) Warum IndexedDB statt localStorage?
| Merkmal | localStorage | IndexedDB |
|---|---|---|
| Speichergrenze | ~5-10 MB | 50+ MB (oft GB) |
| Datentypen | nur Strings | Objekte, Blobs, Arrays |
| Asynchron | nein (blockiert) | ja |
| Indizierte Abfragen | nein | ja |
Für Spiele ist IndexedDB fast immer die richtige Wahl.
2) Eine Datenbank öffnen
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) Spielstand speichern
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) Spielstand laden
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) Alle Spielstände auflisten
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) Einen Spielstand löschen
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) Eine vollständige GameStorage-Klasse
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) Binärdaten ablegen (Texturen, Audio)
IndexedDB kommt mit Blobs und ArrayBuffers zurecht:
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) Muster für Autosave
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) Fehlerbehandlung und Rückfallwege
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) Spielstände vor dem Verdrängen schützen
IndexedDB bleibt nicht garantiert erhalten. Standardmäßig nutzt eine Origin "Best-Effort"-Speicher, und der Browser darf ihn räumen, wenn die Platte volläuft oder, unter Safari/WebKit, nach längerer Zeit ohne Nutzerinteraktion mit deiner Seite. Bei Spielständen sind das genau die Daten, die du nicht verlieren willst.
Fordere persistenten Speicher an, damit der Browser deine Daten nicht ohne ausdrückliche Nutzeraktion löscht:
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
}Browser entscheiden anhand von Interaktionssignalen, ob sie das gewähren (wie stark der Nutzer mit deiner Seite interagiert, ob sie als PWA installiert ist), nimm also nicht an, dass es immer klappt. Du kannst auch prüfen, wie viel Platz du hast, bevor du große Spielstände oder gecachte Assets schreibst:
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate()
console.log(`Using ${usage} of ${quota} bytes`)
}
}Beide APIs brauchen einen sicheren Kontext (HTTPS oder localhost).
Verwandt
- PWA für Offline-Spiele
- Service Worker fürs Spiel-Caching
- Ein Web-Spiel ausliefern, das schnell lädt
- Streaming von Assets — IndexedDB als Asset-Cache nutzen
- Analytik für Web-Spiele — Speicher- und Lademuster verfolgen, um Spielerverhalten zu verstehen
Externe Ressourcen
- MDN: IndexedDB API — vollständige API-Referenz
- MDN: Using IndexedDB — Schritt-für-Schritt-Anleitung
- idb library — ein winziger Promise-basierter IndexedDB-Wrapper