Sauvegarder et charger l’état du jeu avec IndexedDB
IndexedDB est la meilleure option pour stocker durablement les données d’un jeu dans le navigateur. Il gère de grandes quantités de données, fonctionne hors ligne et ne bloque pas le thread principal.
1) Pourquoi choisir IndexedDB plutôt que localStorage ?
| Fonctionnalité | localStorage | IndexedDB |
|---|---|---|
| Limite de stockage | ~5 à 10 Mo | 50 Mo et plus (souvent des Go) |
| Types de données | Chaînes uniquement | Objets, blobs, tableaux |
| Asynchrone | Non (bloquant) | Oui |
| Requêtes indexées | Non | Oui |
Pour les jeux, IndexedDB est presque toujours le bon choix.
2) Ouvrir une base de données
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) Sauvegarder l’état du jeu
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) Charger l’état du jeu
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) Lister toutes les sauvegardes
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) Supprimer une sauvegarde
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) Une classe GameStorage complète
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) Stocker des données binaires (textures, audio)
IndexedDB prend en charge les Blobs et les 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) Modèle de sauvegarde automatique
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) Gestion des erreurs et solutions de repli
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) Protéger les sauvegardes contre l’éviction
La persistance d’IndexedDB n’est pas garantie. Par défaut, une origine utilise un stockage « au mieux », que le navigateur peut évincer lorsque le disque est plein ou, avec Safari/WebKit, après une période sans interaction de l’utilisateur avec votre site. Pour des sauvegardes de jeu, ce sont précisément les données que vous ne voulez pas perdre.
Demandez un stockage persistant afin que le navigateur n’efface pas vos données sans action explicite de l’utilisateur :
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
}Les navigateurs décident d’accorder ou non cette autorisation selon des signaux d’engagement, comme la fréquence d’interaction de l’utilisateur avec votre site ou son installation en tant que PWA. Ne supposez donc pas que la demande réussira toujours. Vous pouvez également vérifier l’espace disponible avant d’écrire de grandes sauvegardes ou de mettre des ressources en cache :
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate()
console.log(`Using ${usage} of ${quota} bytes`)
}
}Ces deux API nécessitent un contexte sécurisé (HTTPS ou localhost).
À lire aussi
- PWA pour les jeux hors ligne
- Service workers pour la mise en cache des jeux
- Publier un jeu web qui se charge rapidement
- Chargement progressif des ressources — utiliser IndexedDB comme cache de ressources
- Analytique pour les jeux web — suivre les habitudes de sauvegarde et de chargement pour comprendre le comportement des joueurs
Ressources externes
- MDN : API IndexedDB — référence complète de l’API
- MDN : Utiliser IndexedDB — guide étape par étape
- Bibliothèque idb — un petit wrapper IndexedDB fondé sur les promesses