Skip to content

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élocalStorageIndexedDB
Limite de stockage~5 à 10 Mo50 Mo et plus (souvent des Go)
Types de donnéesChaînes uniquementObjets, blobs, tableaux
AsynchroneNon (bloquant)Oui
Requêtes indexéesNonOui

Pour les jeux, IndexedDB est presque toujours le bon choix.

2) Ouvrir une base de données

js
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

js
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

js
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

js
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

js
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

js
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 :

js
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

js
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

js
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 :

js
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 :

js
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

Ressources externes