PWA para juegos web sin conexión
Las aplicaciones web progresivas permiten que los jugadores instalen tu juego y jueguen sin conexión. Este tutorial muestra cómo añadir compatibilidad con PWA a un juego web.
1) Manifiesto de la aplicación web
Crea manifest.json:
{
"name": "Mi juego increíble",
"short_name": "JuegoIncreíble",
"description": "Un juego increíble al que puedes jugar sin conexión",
"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"
}
]
}Enlázalo en tu 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 básico
Crea 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) Registrar el 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) Priorizar la caché con actualización desde la red
Es mejor para juegos: carga rápida con actualizaciones en segundo plano:
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) Caché con versiones para las actualizaciones
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) Gestionar las actualizaciones del juego
Avisa a los jugadores cuando haya una actualización 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>¡Juego actualizado! Recarga la página para obtener la última versión.</p>
<button onclick="location.reload()">Recargar</button>
`
banner.className = 'update-banner'
document.body.appendChild(banner)
}7) Detección del estado sin conexión
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) Aviso de instalación
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
})En iOS y iPadOS, el evento beforeinstallprompt nunca se activa, por lo que el botón de instalación anterior solo aparece en navegadores basados en Chromium. Los usuarios de iPhone y iPad pueden instalar la aplicación tocando Compartir y después «Añadir a pantalla de inicio». Hay un cambio de 2026 que conviene conocer: a partir de Safari 26 (iOS 26 / iPadOS 26), todos los sitios añadidos a la pantalla de inicio se abren como aplicaciones web de forma predeterminada, con la opción «Abrir como aplicación web» activada por defecto. Ya no existe un requisito de instalabilidad en iOS, aunque incluir un manifiesto y un service worker sigue ofreciendo una experiencia sin conexión mucho mejor.
9) Sincronización en segundo plano para las clasificaciones
Importante sobre la compatibilidad: la API Background Sync solo está implementada en navegadores basados en Chromium (Chrome, Edge, Opera y Samsung Internet). Firefox y Safari (incluido iOS) no la admiten, así que detecta siempre si la función está disponible y conserva una alternativa que envíe las puntuaciones la próxima vez que la aplicación se cargue normalmente.
// 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) Probar la PWA
Chrome DevTools:
- Application > Service Workers
- Application > Manifest
- Application > Cache Storage
- Network > Casilla Offline
Auditoría de Lighthouse:
- Ejecuta la auditoría de PWA
- Comprueba la instalabilidad
- Comprueba el funcionamiento sin conexión
Pruebas en un dispositivo real:
- Instálala en la pantalla de inicio del teléfono
- Activa el modo avión
- Prueba todas las funciones sin conexión
Contenido relacionado
- Service workers para almacenar el juego en caché
- Partidas guardadas con IndexedDB
- Publica un juego web que cargue rápido
- Juegos web adaptados a dispositivos móviles — controles táctiles y gestión del viewport para juegos PWA
- Carga progresiva de recursos — estrategias de carga compatibles con el almacenamiento en caché del service worker
Recursos externos
- MDN: Aplicaciones web progresivas — documentación completa sobre PWA
- web.dev: Aprende PWA — ruta de aprendizaje de Google sobre PWA
- MDN: Manifiesto de aplicación web — referencia del archivo de manifiesto