๐พ localStorage โ Saving in the Browser
The browser can persistently store data โ without a user database, server, or internet connection. Castle Siege and Galactic Conquest use this to save level progress and the leaderboard.
1What is localStorage?
localStorage is a key-value store that lives in the browser and persists after closing the tab. It's like a mini database, accessible from JavaScript โ with no server required.
| Location | Persists after closing? | Size | Good for? |
|---|---|---|---|
let x = 5 | โ No | unlimited | Runtime data |
sessionStorage | โ No (deleted when tab closes) | ~5 MB | Temporary session data |
localStorage | โ Yes | ~5 MB | Saves, settings, leaderboard |
| Server database | โ Yes | unlimited | Multiple users, synchronisation |
The 5 core methods
// SAVE โ stores text as a key-value pair localStorage.setItem('player_name', 'Alex'); // READ โ returns the value (or null if it doesn't exist) const name = localStorage.getItem('player_name'); // 'Alex' // DELETE โ removes one key localStorage.removeItem('player_name'); // DELETE ALL โ dangerous! removes everything localStorage.clear(); // COUNT KEYS const count = localStorage.length; // how many keys are saved
localStorage always expects and returns strings. Numbers and objects must be converted to JSON. That's why we need JSON.stringify() and JSON.parse().
2JSON โ saving objects
The game state is a complex object: level number, gold, towers, settings. It needs to be saved as a single string โ that's what JSON does.
// โโโ SAVE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ function saveGame() { const saved = { level: G.level, // current level gold: G.gold, // gold highscore: G.highscore // best score }; // Object โ string (JSON) const text = JSON.stringify(saved); // String โ localStorage localStorage.setItem('castle_siege_save', text); console.log('Saved!', text); // e.g.: '{"level":7,"gold":340,"highscore":12500}' } // โโโ LOAD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ function loadGame() { const text = localStorage.getItem('castle_siege_save'); // If no saved data, we get null if (!text) { console.log('No saved game.'); return; } // String โ Object (JSON) const saved = JSON.parse(text); G.level = saved.level; G.gold = saved.gold; G.highscore = saved.highscore; console.log('Loaded! Level: ' + G.level); }
stringify(obj) โ turns an object into a string. parse(str) โ turns a string back into an object. If you saved with stringify, you must read with parse.
Safe loading โ always use try-catch
function loadGame() { try { const str = localStorage.getItem('castle_siege_save'); if (!str) return false; // no save const d = JSON.parse(str); // Validate: is the saved data valid? if (typeof d.level !== 'number') return false; G.level = d.level; G.gold = d.gold || 100; // default value if missing return true; } catch(e) { // Corrupted JSON won't crash the game console.warn('Load error:', e); localStorage.removeItem('castle_siege_save'); return false; } }
If the string in localStorage is corrupted (e.g. interrupted save, data overwritten by another page), JSON.parse() throws an exception and the game crashes. try-catch catches the error and handles it safely.
3Auto-save
// Automatically saves on every level change function nextLevel() { G.level++; G.gold += 50; // reward gold saveGame(); // โ autosave after every level genLevel(G.level); // level generation } // Load on game start window.addEventListener('load', () => { const ok = loadGame(); if (ok) { showMessage('Welcome back! You are on level ' + G.level + '.'); } genLevel(G.level); });
Open castle_siege.html in VS Code and find the localStorage calls:
- Press F12 in the browser, then Application โ Local Storage โ can you see the saved data?
- Play through 2 levels, then refresh the page โ does it return to your level?
- Write a
clearSave()function that deletes the save and resets to level 1 - Attach it to a "New Game" button
๐ง What does localStorage.getItem('key') return if the key doesn't exist?
๐ Implementing a Top 10 Leaderboard
A combination of array sorting, slice(), and localStorage โ this is how we store the best results list with names and scores. We learn from the Galactic Conquest leaderboard.
1The leaderboard data structure
The leaderboard is an array where each element is an object: {name, score, date}. We store the best 10 โ sorted in descending order by score.
// Load leaderboard from localStorage function loadScores() { try { const str = localStorage.getItem('galactic_scores'); return str ? JSON.parse(str) : []; } catch { return []; } } // Add a new result to the leaderboard function addScore(name, score) { const scores = loadScores(); // 1. Add the new result scores.push({ name: name, score: score, date: new Date().toLocaleDateString('en-GB') }); // 2. Sort: descending by score (b-a = larger first) scores.sort((a, b) => b.score - a.score); // 3. Keep only the top 10 const top10 = scores.slice(0, 10); // 4. Save back localStorage.setItem('galactic_scores', JSON.stringify(top10)); return top10; }
The array.sort((a, b) => b.score - a.score) compares two elements. If the result is negative, no swap occurs. If positive, they are swapped. The b - a formula always puts the larger value first (descending order).
slice() โ only the best
const scores = [300, 1200, 850, 75, 2100, 440]; // sort: sorts in-place, modifies the original array scores.sort((a, b) => b - a); // โ [2100, 1200, 850, 440, 300, 75] // slice(start, end): returns a copy, does NOT modify the original const top3 = scores.slice(0, 3); // โ [2100, 1200, 850] // The original scores array is unchanged! // Difference between splice and slice: // splice โ modifies the original (can also remove/insert elements) // slice โ only returns a copy, doesn't touch the original
2Displaying the leaderboard on canvas
// Draw the leaderboard onto the canvas function drawLeaderboard() { const scores = loadScores(); const medals = ['๐ฅ', '๐ฅ', '๐ฅ']; ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(0, 0, W, H); // dark background ctx.font = 'bold 28px Inter'; ctx.fillStyle = '#ffe066'; ctx.fillText('๐ TOP 10', W/2, 60); scores.forEach((s, i) => { const y = 110 + i * 36; // Highlight the top three ctx.fillStyle = i < 3 ? '#fff' : '#8895aa'; ctx.font = i < 3 ? 'bold 18px Inter' : '16px Inter'; // Medal for top 3, number for the rest const prefix = i < 3 ? medals[i] : `${i+1}. `; ctx.fillText(`${prefix} ${s.name} โ ${s.score} pts`, 60, y); // Date on the right ctx.fillStyle = '#4a6272'; ctx.font = '13px Inter'; ctx.fillText(s.date, W - 110, y); }); }
Build a simple leaderboard system:
- Make an
addScore('Test', 9999)call and check F12 โ Application โ Local Storage for the result - Call it 5 times with different names and scores โ what remains?
- Modify it to store Top 5 instead of Top 10
- Add a filter: the same name can only appear once (with their best score)
๐ง What does [5,2,8,1].sort((a,b) => b - a) do?
๐ Web Audio API โ Music and Sound Effects
Playing sound in a browser with three approaches: HTML5 Audio element, embedded base64 audio, and the advanced AudioContext API. Why isn't the simplest solution always enough?
1The simplest: new Audio()
The Audio object is the browser's built-in audio player. It takes a filename and can play it immediately.
// Load audio from file const bgm = new Audio('frog_music1.mp3'); bgm.loop = true; // enable looping bgm.volume = 0.6; // volume: 0.0 โ 1.0 // Playback โ important: user interaction required! document.addEventListener('click', () => { bgm.play().catch(e => console.warn('Audio error:', e)); }, { once: true }); // only on the first click // Stop and rewind bgm.pause(); bgm.currentTime = 0; // rewind to the beginning
Since 2018, browsers block automatic audio playback โ the user must first interact with the page (click, key press). That's why in most games, music only starts on the first click.
2Base64 audio embedding โ mobile solution
On Android, the browser often fails to load external mp3 files โ especially if the game runs locally (from the filesystem, without a server). The solution: embed the audio directly into the HTML file using base64 encoding.
// The base64 code looks like this (shortened): // data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2... // (in practice hundreds of thousands of characters โ the full audio file) // We give this to the Audio object as its source const MUSIC_DATA = 'data:audio/mp3;base64,SUQzBAAA...'; // full data const bgm = new Audio(MUSIC_DATA); bgm.loop = true; // Sound effects work the same way โ small sounds as base64 const SFX_HIT = new Audio('data:audio/wav;base64,UklGRiQ...'); const SFX_GOLD = new Audio('data:audio/wav;base64,UklGRjQ...'); // Playing a sound effect (clone โ multiple can play simultaneously) function playSound(audio) { const clone = audio.cloneNode(); clone.volume = 0.7; clone.play().catch(() => {}); }
How do we encode audio to base64?
// Run with Node.js: node convert.js const fs = require('fs'); const data = fs.readFileSync('music.mp3'); const b64 = 'data:audio/mp3;base64,' + data.toString('base64'); fs.writeFileSync('music_b64.txt', b64); console.log('Done! Size: ' + b64.length + ' characters'); // Alternative: in the browser, drag & drop solution // โ btoa() is not suitable for mp3, because it's binary data // โ use the FileReader API in the browser: const reader = new FileReader(); reader.onload = e => console.log(e.target.result); // the b64 string reader.readAsDataURL(file); // file = input[type=file] .files[0]
If you play the same Audio object twice in quick succession (e.g. two enemies die at once), the second play() jumps to the end of the first. cloneNode() creates a copy โ so multiple sound effects can play in parallel simultaneously.
3AudioContext โ advanced audio handling
The AudioContext is the browser's complete audio editing system: volume control, effects, mixing, synthesised sound generation. In game development we mainly use it for volume and musical effects.
// Create the audio system const actx = new AudioContext(); // Synthesised "coin collected" sound โ without any audio file! function beep(hz = 440, duration = 0.15, volume = 0.3) { const osc = actx.createOscillator(); // oscillator generator const gain = actx.createGain(); // volume controller osc.connect(gain).connect(actx.destination); osc.frequency.value = hz; // 440 Hz = A note (A4) osc.type = 'sine'; // waveform: sine / square / sawtooth gain.gain.value = volume; // volume level // Fade out at the end (envelope) gain.gain.exponentialRampToValueAtTime(0.001, actx.currentTime + duration); osc.start(); osc.stop(actx.currentTime + duration); } // Usage: beep(880, 0.1); // sharp pip โ score gained beep(220, 0.4); // deep hum โ life lost beep(1047, 0.2); // high chime โ level complete
new Audio(file) โ simple sounds from external mp3/wav files.
new Audio(base64) โ mobile/offline compatible, no file needed.
AudioContext beep() โ synthesised sounds, no audio file, for small games.
Add sound to your own mini game:
- Create a
beep()function based on the code above - Play it on score gain (high sound) and life loss (low sound)
- Open F12 โ Console โ do you get an "AudioContext was not allowed to start" warning?
- Fix it: start the AudioContext from a click event
๐ง Why isn't audio.play() enough when the game starts?
๐ฑ Mobile D-pad and Touch Controls
Games need to be playable on mobile โ but there's no keyboard. The solution: directional buttons drawn over the canvas that respond to touch events. This is how we built the mobile controls for Froggy Rush and Castle Siege.
1Touch vs Pointer events โ which do we use?
| Event type | Compatibility | Advantage | Disadvantage |
|---|---|---|---|
touchstart / touchend | Touch screens only | Older devices too | Separate mouse and touch code needed |
pointerdown / pointerup | Mouse + touch + stylus | One code for all three | IE11 not supported |
click | Everywhere | Simplest | ~300ms delay on mobile, no multi-touch |
For modern games, use pointerdown / pointerup / pointermove events โ these work uniformly with both mouse and touchscreen, from a single code base.
2D-pad implementation
// โโโ 1. D-PAD BUTTON DEFINITIONS โโโโโโโโโโโโโโโโโโโโโโโโโโโ const DPAD = [ { id: 'up', x:75, y:40, w:50, h:50, label:'โฒ' }, { id: 'down', x:75, y:140, w:50, h:50, label:'โผ' }, { id: 'left', x:20, y:90, w:50, h:50, label:'โ' }, { id: 'right', x:130, y:90, w:50, h:50, label:'โถ' }, ]; // Which buttons are currently pressed const pressed = new Set(); // โโโ 2. CANVAS COORDINATE CALCULATION โโโโโโโโโโโโโโโโโโโโโ function getCanvasPos(e) { const r = canvas.getBoundingClientRect(); // Scale ratio: if the canvas has a different CSS size const scaleX = canvas.width / r.width; const scaleY = canvas.height / r.height; return { x: (e.clientX - r.left) * scaleX, y: (e.clientY - r.top) * scaleY }; } // โโโ 3. HIT-TEST: which button was tapped? โโโโโโโโโโโโโโโโโ function hitDpad(px, py) { // D-pad is in the bottom-left corner of the canvas const offX = 10, offY = H - 200; // D-pad top-left anchor point for (const btn of DPAD) { if (px >= offX+btn.x && px <= offX+btn.x+btn.w && py >= offY+btn.y && py <= offY+btn.y+btn.h) { return btn.id; // 'up', 'down', 'left' or 'right' } } return null; } // โโโ 4. EVENT HANDLERS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ canvas.addEventListener('pointerdown', e => { e.preventDefault(); // block scroll const pos = getCanvasPos(e); const btn = hitDpad(pos.x, pos.y); if (btn) pressed.add(btn); }); canvas.addEventListener('pointerup', e => { const pos = getCanvasPos(e); const btn = hitDpad(pos.x, pos.y); if (btn) pressed.delete(btn); }); // In the game loop: pressed contains the active buttons function update() { if (pressed.has('left') || K.left) p.vx -= 0.8; if (pressed.has('right') || K.right) p.vx += 0.8; if (pressed.has('up') || K.up) jump(); }
A Set automatically filters duplicates: if you press "up" twice, it only appears once. The add() and has() operations are O(1) fast. The same is achievable with an array, but Set is cleaner and faster.
3Drawing the D-pad on canvas
function drawDpad() { // Only show on mobile if (!isMobile()) return; const offX = 10, offY = H - 200; for (const btn of DPAD) { const bx = offX + btn.x, by = offY + btn.y; // Pressed button: different colour ctx.fillStyle = pressed.has(btn.id) ? 'rgba(255,154,60,0.7)' // pressed: orange : 'rgba(255,255,255,0.12)'; // default: semi-transparent // Rounded rectangle (roundRect) ctx.beginPath(); ctx.roundRect(bx, by, btn.w, btn.h, 10); ctx.fill(); // Border ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1.5; ctx.stroke(); // Arrow icon ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.font = 'bold 20px Inter'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(btn.label, bx + btn.w/2, by + btn.h/2); } } // Mobile detection function isMobile() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0; }
Multi-touch โ multiple buttons at once
// Each pointer (finger) gets an ID const activePointers = new Map(); // pointerId โ dpad button canvas.addEventListener('pointerdown', e => { e.preventDefault(); const pos = getCanvasPos(e); const btn = hitDpad(pos.x, pos.y); if (btn) { activePointers.set(e.pointerId, btn); // remember which finger is on which button pressed.add(btn); } }); canvas.addEventListener('pointerup', e => { const btn = activePointers.get(e.pointerId); // which button does this finger belong to? if (btn) { pressed.delete(btn); activePointers.delete(e.pointerId); } });
Add the mobile D-pad to your own game:
- Import the
DPADdefinition and thepressedSet - Register the
pointerdownandpointerupevents - Call
drawDpad()at the end of draw() - Test in Chrome DevTools โ Toggle Device Toolbar (Ctrl+Shift+M) โ does the D-pad appear?
๐ง Why do we use a Set to store pressed buttons?
๐ Deploy โ Your Game Goes Live on the Internet
The finished game can be tried by anyone on their own phone via a link โ without a server, hosting, or developer knowledge. Netlify is free and drag-and-drop simple.
1What is static hosting?
Our games are static files: HTML, CSS, JavaScript โ no database, no server-side code. A static hosting service simply serves these files to the browser, making them accessible to everyone.
| Service | Free plan | Difficulty | Notes |
|---|---|---|---|
| Netlify | โ Yes, unlimited | โญ Easiest | Drag & drop, custom domain possible |
| GitHub Pages | โ Yes | โญโญ Easy | GitHub account needed, git push |
| Vercel | โ Yes | โญโญ Easy | For developers, automatic deploy |
| itch.io | โ Yes | โญ Easiest | Specifically for games, community |
2Netlify โ step by step
Visit netlify.com and click the "Sign up" button. You can create a free account with Google or GitHub.
Make sure all the necessary files are in one folder: index.html, the game HTML files, and the mp3 music tracks.
On the Netlify home page under the "Sites" tab there is a "Drop your site folder here" area. Drag your entire project folder onto this area.
After a few seconds you get a link: some-name-12345.netlify.app. This is your page's URL โ anyone can access it!
Site settings โ Domain management โ Options โ Edit site name. Make it e.g. froggy-rush-alex.netlify.app.
If you need to re-upload after making changes: on the Netlify admin panel under the "Deploys" tab there is a "Drop your site folder here" area โ drag and drop the same way. The URL stays the same and updates automatically.
3Pre-deployment checklist
Before uploading, check the following:
- The main page is named
index.htmlโ this opens automatically - All filenames are lowercase, no accents, no spaces (froggy_rush.html โ , Froggy Rush.html โ)
- Audio files are embedded as base64, or in the correct subdirectory
- All relative links are correct (
href="game.html"nothref="C:/Users/alex/game.html") - Game tested in Chrome and Firefox browsers
- Opened on mobile (Chrome DevTools โ Ctrl+Shift+M) and D-pad works
- localStorage save and load has been tested
4Itch.io โ sharing games with players
itch.io is an indie game platform โ if you want to share your game not just as a link but on a dedicated game page, this is the best choice.
# Windows: right-click folder โ Send to โ Compressed (zip) folder # Mac: right-click โ Compress # Linux: zip -r game.zip game_folder/ # On itch.io: # 1. Register (free) # 2. "Create new project" โ Pricing: Free # 3. Kind of project: HTML # 4. Uploads: upload the zip, check: "This file will be played in the browser" # 5. Viewport dimensions: 480ร700 (portrait) or 800ร600 (landscape) # 6. Save & publish โ done! # Your game will be accessible at: https://username.itch.io/game-name
Upload your own game to the internet:
- Sign up for Netlify (free)
- Make sure index.html is the main page
- Drag the folder onto the Netlify drop area
- Copy the URL you receive and send it to a friend on mobile
- Bonus: set a custom name for the page
๐ง Why is it important that the main page is named exactly index.html?
โWhat you've completed โ summary
| Phase | Topic | Lessons | What you learned |
|---|---|---|---|
| 1. HTML & CSS | Web page structure, styles | 1โ10 | DOCTYPE, tags, CSS, Flexbox, responsive design |
| 2. JavaScript | Programming logic | 11โ20 | Variables, conditions, loops, functions, arrays, objects, events |
| 3. Canvas | Graphics and animation | 21โ30 | Drawing, game loop, animation, collision, camera, sprite animation |
| 4. Games | Full game analysis | 31โ45 | AI, State Machine, level generation, strategy pattern, Tower Defense |
| 5. Advanced | Save, sound, mobile, deploy | 46โ50 | localStorage, JSON, leaderboard, Web Audio, D-pad, Netlify |
โWhat's next?
Pick an idea you love (platform, puzzle, tower defense) and build it from scratch. Anything you get stuck on โ Google, MDN docs, or the game source code is your reference.
The next level of web development: React (components, state management) and Node.js (server side, real database, multiple users).
Phaser.js โ professional HTML5 game engine. Three.js โ 3D graphics with WebGL. Howler.js โ more advanced audio handling.
MDN Web Docs โ the best HTML/JS documentation. JavaScript.info โ for deeper JS learning. itch.io โ share your game.
๐กThe most important lesson
Not by reading, not by watching videos. After completing the course, real progress is in your hands: open a blank HTML file, think of something you want to see on screen, and start writing. The first 10 attempts won't work perfectly โ but the 11th will. That's how everyone learns to program.