PWA pour les jeux web hors ligne
Les Progressive Web Apps permettent aux joueurs d’installer votre jeu et d’y jouer hors ligne. Ce tutoriel explique comment ajouter la prise en charge des PWA à un jeu web.
1) Manifeste d’application web
Créez manifest.json :
{
"name": "Mon jeu génial",
"short_name": "JeuGénial",
"description": "Un jeu génial auquel vous pouvez jouer hors ligne",
"start_url": "/",
"display": "fullscreen",
"orientation": "landscape",
"background_color": "#1a1a2e",
"theme_color": "#4ade80",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}Ajoutez le lien dans votre HTML :
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4ade80">
<link rel="apple-touch-icon" href="/icons/icon-192.png">2) Service worker de base
Créez sw.js :
const CACHE_NAME = 'game-v1'
const ASSETS = [
'/',
'/index.html',
'/game.js',
'/style.css',
'/assets/sprites.png',
'/assets/sounds/jump.mp3',
'/assets/sounds/music.mp3',
]
// Install: cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS)
})
)
})
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
})
)
})
// Fetch: serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request)
})
)
})3) Enregistrer le service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('SW registered:', registration.scope)
} catch (err) {
console.error('SW registration failed:', err)
}
})
}4) Priorité au cache avec mise à jour réseau
Une meilleure approche pour les jeux : chargement rapide et mises à jour en arrière-plan.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(event.request)
// Start network fetch in background
const fetchPromise = fetch(event.request).then((response) => {
if (response.ok) {
cache.put(event.request, response.clone())
}
return response
}).catch(() => null)
// Return cached immediately, or wait for network
return cached || fetchPromise
})
)
})5) Cache versionné pour les mises à jour
const CACHE_VERSION = 'v2'
const STATIC_CACHE = `static-${CACHE_VERSION}`
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`
const STATIC_ASSETS = [
'/',
'/index.html',
'/game.js',
// ... core assets that rarely change
]
self.addEventListener('install', (event) => {
self.skipWaiting() // Activate immediately
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => cache.addAll(STATIC_ASSETS))
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => !key.includes(CACHE_VERSION))
.map(key => caches.delete(key))
)
})
)
clients.claim() // Take control immediately
})6) Gérer les mises à jour du jeu
Informez les joueurs lorsqu’une mise à jour est disponible :
// In main app
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true
showUpdateNotification()
}
})
function showUpdateNotification() {
const banner = document.createElement('div')
banner.innerHTML = `
<p>Jeu mis à jour ! Actualisez la page pour obtenir la dernière version.</p>
<button onclick="location.reload()">Actualiser</button>
`
banner.className = 'update-banner'
document.body.appendChild(banner)
}7) Détecter le mode hors ligne
function updateOnlineStatus() {
if (navigator.onLine) {
hideOfflineBanner()
syncGameData()
} else {
showOfflineBanner()
}
}
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
function showOfflineBanner() {
document.getElementById('offline-banner').style.display = 'block'
}
function hideOfflineBanner() {
document.getElementById('offline-banner').style.display = 'none'
}8) Invite d’installation
let deferredPrompt = null
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt = e
showInstallButton()
})
function showInstallButton() {
const btn = document.getElementById('install-btn')
btn.style.display = 'block'
btn.addEventListener('click', installApp)
}
async function installApp() {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
console.log('Install prompt outcome:', outcome)
deferredPrompt = null
document.getElementById('install-btn').style.display = 'none'
}
window.addEventListener('appinstalled', () => {
console.log('App installed!')
deferredPrompt = null
})Sur iOS et iPadOS, l’événement beforeinstallprompt ne se déclenche jamais. Le bouton d’installation ci-dessus ne s’affiche donc que dans les navigateurs Chromium. Les utilisateurs d’iPhone et d’iPad installent l’application en touchant Partager, puis « Sur l’écran d’accueil ». Un changement intervenu en 2026 mérite d’être signalé : à partir de Safari 26 (iOS 26 / iPadOS 26), chaque site ajouté à l’écran d’accueil s’ouvre par défaut comme une application web, avec une option « Ouvrir comme app web » activée par défaut. Il n’existe plus d’exigence d’installabilité sur iOS, même si fournir un manifeste et un service worker offre toujours une bien meilleure expérience hors ligne.
9) Synchronisation en arrière-plan des classements
Attention à la compatibilité : l’API Background Sync n’est implémentée que dans les navigateurs Chromium (Chrome, Edge, Opera et Samsung Internet). Firefox et Safari, y compris sur iOS, ne la prennent pas en charge. Détectez donc toujours cette fonctionnalité et prévoyez une solution de repli qui envoie les scores au prochain chargement normal de l’application.
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-scores') {
event.waitUntil(syncScores())
}
})
async function syncScores() {
const db = await openDB('game', 1)
const pendingScores = await db.getAll('pending-scores')
for (const score of pendingScores) {
try {
await fetch('/api/scores', {
method: 'POST',
body: JSON.stringify(score),
headers: { 'Content-Type': 'application/json' }
})
await db.delete('pending-scores', score.id)
} catch (err) {
// Will retry on next sync
break
}
}
}
// In main app
async function submitScore(score) {
try {
await fetch('/api/scores', { method: 'POST', body: JSON.stringify(score) })
} catch {
// Save for later sync
const db = await openDB('game', 1)
await db.add('pending-scores', { ...score, id: Date.now() })
if ('serviceWorker' in navigator && 'sync' in window.registration) {
await navigator.serviceWorker.ready
await registration.sync.register('sync-scores')
}
}
}10) Tester la PWA
Chrome DevTools :
- Application > Service Workers
- Application > Manifest
- Application > Cache Storage
- Network > Case à cocher Offline
Audit Lighthouse :
- Exécuter l’audit PWA
- Vérifier l’installabilité
- Vérifier le fonctionnement hors ligne
Test sur un appareil réel :
- Installer le jeu sur l’écran d’accueil du téléphone
- Activer le mode avion
- Tester toutes les fonctionnalités hors ligne
À lire aussi
- Service workers pour la mise en cache des jeux
- Sauvegardes de jeu avec IndexedDB
- Publier un jeu web qui se charge rapidement
- Jeux web adaptés aux appareils mobiles — commandes tactiles et gestion de la fenêtre d’affichage pour les jeux PWA
- Chargement progressif des ressources — stratégies de chargement compatibles avec la mise en cache par service worker
Ressources externes
- MDN : Progressive Web Apps — documentation complète sur les PWA
- web.dev : découvrir les PWA — parcours d’apprentissage des PWA proposé par Google
- MDN : manifeste d’application web — référence du fichier manifeste