Skip to content

PWA untuk game web offline

Progressive Web App memungkinkan pemain menginstal game Anda dan memainkannya secara offline. Tutorial ini menunjukkan cara menambahkan dukungan PWA ke game web.

1) Manifes Aplikasi Web

Buat manifest.json:

json
{
  "name": "Game Keren Saya",
  "short_name": "GameKeren",
  "description": "Game keren yang dapat Anda mainkan secara offline",
  "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"
    }
  ]
}

Tautkan manifes tersebut di HTML Anda:

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 dasar

Buat sw.js:

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) Daftarkan service worker

js
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) Cache-first dengan pembaruan jaringan

Lebih baik untuk game—pemuatan cepat dengan pembaruan di latar belakang:

js
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 berversi untuk pembaruan

js
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) Menangani pembaruan game

Beri tahu pemain saat pembaruan tersedia:

js
// 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>Game telah diperbarui! Muat ulang untuk mendapatkan versi terbaru.</p>
    <button onclick="location.reload()">Muat ulang</button>
  `
  banner.className = 'update-banner'
  document.body.appendChild(banner)
}

7) Deteksi offline

js
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) Prompt penginstalan

js
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
})

Di iOS dan iPadOS, peristiwa beforeinstallprompt tidak pernah dipicu, sehingga tombol instal di atas hanya muncul di browser Chromium. Pengguna iPhone dan iPad dapat menginstal dengan mengetuk Bagikan, lalu "Tambahkan ke Layar Utama". Ada satu perubahan pada tahun 2026 yang perlu diketahui: mulai Safari 26 (iOS 26 / iPadOS 26), setiap situs yang ditambahkan ke Layar Utama akan terbuka sebagai aplikasi web secara default, dengan tombol alih "Buka sebagai Aplikasi Web" yang aktif secara default. Kini tidak ada lagi persyaratan agar situs dapat diinstal di iOS, meskipun menyediakan manifes dan service worker tetap memberikan pengalaman offline yang jauh lebih baik.

9) Sinkronisasi latar belakang untuk papan peringkat

Perhatikan dukungannya: Background Sync API hanya diimplementasikan di browser Chromium (Chrome, Edge, Opera, Samsung Internet). Firefox dan Safari (termasuk iOS) tidak mendukungnya, jadi selalu lakukan deteksi fitur dan sediakan fallback yang mengirimkan skor saat aplikasi dimuat secara normal berikutnya.

js
// 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) Menguji PWA

Chrome DevTools:

  • Application > Service Workers
  • Application > Manifest
  • Application > Cache Storage
  • Network > Kotak centang Offline

Audit Lighthouse:

  • Jalankan audit PWA
  • Periksa apakah aplikasi dapat diinstal
  • Periksa kemampuan offline

Pengujian di perangkat nyata:

  • Instal di layar utama ponsel
  • Aktifkan mode pesawat
  • Uji semua fitur secara offline

Terkait

Referensi Eksternal