Small steps add up. Complete a chapter, test your understanding, or keep a weekly streak.
+100 XPComplete a chapter
+5–15 XPAnswer a knowledge check
+150 XPEarn a 7-day streak badge
Badge collection
Every badge on IoTClass has its own illustration — 1,090 designs in all: 983 chapter badges, 41 module badges, 41 module-mastery crowns, 5 learning-path badges, 5 path-mastery crowns, and 15 milestone, streak and quiz badges.
Badges are free. Sign in with Google and they attach to your account instead of to one browser, so your XP, level, streak and every badge you earn follow you between devices. Nothing here expires and nothing here can be taken away.
This page is the gallery — what the artwork looks like and what earns it. For the full mechanics (every XP value, how streaks count, what happens when a chapter you already completed gets updated), read Badges & Progress in the How IoTClass Works guide.
Show code
supportServices = {const wait = (ms) =>newPromise((resolve) =>setTimeout(resolve, ms));const waitFor =async (getter, timeoutMs =5000) => {const deadline =Date.now() + timeoutMs;while (Date.now() < deadline) {const value =getter();if (value) {return value; }awaitwait(100); }returnnull; };const auth =awaitwaitFor(() => {const candidate =window.Auth;return candidate &&typeof candidate.getCurrentUser==='function'&&typeof candidate.isUserAuthenticated==='function'? candidate:null; });const api =awaitwaitFor(() => {const candidate =window.IoTClassAPI;return candidate && candidate.User&& candidate.Badge? candidate :null; });const badgeSystem =awaitwaitFor(() => {const candidate =window.BadgeSystem;return candidate && (candidate.badgeCatalog||typeof candidate.init==='function')? candidate:null; },3000);return { auth, api, badgeSystem,isAuthenticated:Boolean(auth?.isUserAuthenticated?.()), };}// Colorscolors = ({navy:'#2C3E50',teal:'#0F766E',orange:'#E67E22',gray:'#7F8C8D',lightGray:'#ECF0F1'})// A 1,090-definition catalog is too large to paint in one pass, so the// signed-in grid renders a capped page and reports the full total.MAX_RENDERED_BADGES =96normalizeCategoryKey = (value) => {returnString(value ||'uncategorized').trim().toLowerCase().replace(/[_\s]+/g,'-');}formatCategoryLabel = (key) => {return key.split('-').filter(Boolean).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');}badgeIconSvg = (kind ='award') => {const shapes = {award:'<circle cx="12" cy="8" r="5"></circle><path d="m8.5 12-1 9 4.5-2.5 4.5 2.5-1-9"></path>',lock:'<rect x="5" y="10" width="14" height="11" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path>' };return`<svg class="badge-inline-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes[kind] || shapes.award}</svg>`;}renderBadgeArt = (badge) => {const badgeSystem = supportServices.badgeSystem;if (badgeSystem?.renderBadgeImage) {return badgeSystem.renderBadgeImage(badge, {size:256,alt: badge.name|| badge.badge_name||'IoTClass badge',className:'badge-art-image' }); }returnbadgeIconSvg('award');}// Check authenticationcurrentUser = {const auth = supportServices.auth;if (!auth) {returnnull; }const user = auth.getCurrentUser();if (!user) {returnnull; }// Get user profile from Supabaseconst userApi = supportServices.api?.User;const userData = userApi?.getProfile?await userApi.getProfile(user.id): {};return {...user,...userData };}// Full badge catalog from badge_definitionsbadgeCatalog = {const badgeSystem = supportServices.badgeSystem;if (badgeSystem?.init) {await badgeSystem.init(); }if (Array.isArray(badgeSystem?.badgeCatalog) && badgeSystem.badgeCatalog.length>0) {return badgeSystem.badgeCatalog.map(badge => ({...badge,xp_reward: badge.xp_reward||0 })); }const badgeApi = supportServices.api?.Badge;if (badgeApi?.getAllBadgeDefinitions) {const definitions =await badgeApi.getAllBadgeDefinitions();if (definitions && definitions.length>0) {return definitions.map(badge => ({...badge,xp_reward: badge.xp_reward||0 })); } }return [];}// User's earned badges - Using Supabase via BadgeSystem or direct APIuserBadges = {if (!supportServices.isAuthenticated||!currentUser) {return []; }try {const badgeSystem = supportServices.badgeSystem;const badgeApi = supportServices.api?.Badge;// Try BadgeSystem first (it may have more details)if (badgeSystem?.getUserBadgesWithDetails) {const badges =await badgeSystem.getUserBadgesWithDetails(currentUser.id);return badges; }// Fallback to direct Supabase APIif (!badgeApi?.getUserBadges) {return []; }const badges =await badgeApi.getUserBadges(currentUser.id);return badges.map(b => ({...b,badgeId: b.badge_id,earnedDate: b.earned_at })); } catch (error) {console.error('Error loading user badges:', error);return []; }}categoryMeta = {const defaults = {chapter: { label:'Chapter',accent:'#0F766E' },module: { label:'Module',accent:'#2C3E50' },mastery: { label:'Mastery',accent:'#B7791F' },'learning-path': { label:'Learning Path',accent:'#1D4ED8' },milestone: { label:'Milestone',accent:'#E67E22' },achievement: { label:'Achievement',accent:'#16A085' },streak: { label:'Streak',accent:'#C0392B' },specialty: { label:'Specialty',accent:'#6B21A8' },special: { label:'Special',accent:'#8E44AD' },uncategorized: { label:'Other',accent:'#7F8C8D' } };const meta = {};const register = (rawCategory) => {const key =normalizeCategoryKey(rawCategory);if (!meta[key]) {const fallback = defaults.uncategorized; meta[key] = defaults[key] || {label:formatCategoryLabel(key),accent: fallback.accent }; }return key; }; badgeCatalog.forEach(badge =>register(badge.category)); userBadges.forEach(badge => {register( badge.category|| badge.badge_category|| badge.badgeCategory|| badge.badge_definition?.category|| badge.badgeDefinition?.category ); });if (Object.keys(meta).length===0) {register('milestone'); }return meta;}// Badge progress statisticsbadgeStats = {const earnedCount = userBadges.length;const totalCount = badgeCatalog.length;const completionPercent = totalCount >0?Math.round((earnedCount / totalCount) *100) :0;const makeBucket = (key) => ({earned:0,total:0,label: categoryMeta[key]?.label||formatCategoryLabel(key),accent: categoryMeta[key]?.accent|| colors.gray });const byCategory = {};Object.keys(categoryMeta).forEach((key) => { byCategory[key] =makeBucket(key); });const resolveBadgeCategory = (badge) => {returnnormalizeCategoryKey( badge.category|| badge.badge_category|| badge.badgeCategory|| badge.badge_definition?.category|| badge.badgeDefinition?.category ); }; badgeCatalog.forEach((badge) => {const key =resolveBadgeCategory(badge);if (!byCategory[key]) { byCategory[key] =makeBucket(key); } byCategory[key].total++; }); userBadges.forEach((badge) => {const key =resolveBadgeCategory(badge);if (!byCategory[key]) { byCategory[key] =makeBucket(key); } byCategory[key].earned++; });// Total XP from badgesconst totalBadgeXP = userBadges.reduce((sum, badge) => sum + (badge.xp_reward||0),0);const categoryKeys =Object.keys(byCategory).filter((key) => byCategory[key].total>0|| byCategory[key].earned>0).sort((left, right) => {return byCategory[right].total- byCategory[left].total|| left.localeCompare(right); });return { earnedCount, totalCount, completionPercent, byCategory, categoryKeys, totalBadgeXP };}// Filter state.//// These are `mutable` (not plain) cells: the view-tabs/category-filter cell// below registers real addEventListener click handlers on the rendered// buttons, and those handlers reassign `mutable view`/`mutable// categoryFilter` to change state. Inline HTML onclick="..." attributes// cannot do this — Quarto's OJS runtime parses cell-returned HTML strings// with the browser's own HTML parser, so any onclick attribute in that// markup compiles to a handler evaluated in global/window scope, which has// no visibility into an OJS module binding. `mutable` cells are the// documented Observable/OJS mechanism for state a DOM event handler// (registered from inside an OJS cell, so it's a real JS closure rather// than a re-parsed attribute string) can update and have dependent cells// (filteredBadges, visibleBadges, the tabs/grid cells below) re-run// reactively.mutable view ='all'// all, earned, not-earnedmutable categoryFilter ='all'// all or a normalized category key// Filtered badgesfilteredBadges = {const earnedBadgeIds =newSet( userBadges.map((badge) => badge.badgeId|| badge.badge_id|| badge.id).filter(Boolean) );let badges = badgeCatalog;// Apply view filterif (view ==='earned') { badges = badges.filter(badge => earnedBadgeIds.has(badge.id) ); } elseif (view ==='not-earned') { badges = badges.filter(badge =>!earnedBadgeIds.has(badge.id) ); }// Apply category filterif (categoryFilter !=='all') { badges = badges.filter(badge =>normalizeCategoryKey(badge.category) === categoryFilter); }// Merge with earned data, then surface what you already hold first.return badges.map(badge => {const earned = userBadges.find(e => (e.badgeId|| e.badge_id|| e.id) === badge.id);const categoryKey =normalizeCategoryKey(badge.category);const categoryInfo = categoryMeta[categoryKey] || {label:formatCategoryLabel(categoryKey),accent: colors.gray };return {...badge,category: categoryKey,categoryLabel: categoryInfo.label,categoryAccent: categoryInfo.accent,earned: earnedBadgeIds.has(badge.id),earnedDate: earned?.earnedDate|| earned?.earned_at }; }).sort((left, right) => {if (left.earned!== right.earned) return left.earned?-1:1;return (right.xp_reward||0) - (left.xp_reward||0) ||String(left.name||'').localeCompare(String(right.name||'')); });}// The live catalog runs to ~1,090 definitions. Painting all of them at once// stalls the page, so the grid renders a capped page (earned badges first)// and reports the full total underneath.visibleBadges = filteredBadges.slice(0, MAX_RENDERED_BADGES)
Show code
// Signed-out call to action.html`${!supportServices.isAuthenticated?` <div class="badge-signin-panel"> <div class="badge-signin-icon">${badgeIconSvg('lock')}</div> <div class="badge-signin-title" role="heading" aria-level="2">Sign in to Earn Badges</div> <p class="badge-signin-copy"> Free with Google. Your XP, level, streak and badges follow you between devices. </p> <button class="badge-signin-btn" onclick="window.Auth && typeof window.Auth.login === 'function' && window.Auth.login('google')" > <span aria-hidden="true">G</span> Sign in with Google </button> </div>`:''}`
Show code
// Signed-in progress summary, view tabs and category filters.//// The tabs/filter buttons used to carry onclick="viewState.view = 'all'"// etc. Those never worked: this cell hands its whole markup to `html` as a// single already-built string, so Quarto's OJS runtime parses it with the// browser's native HTML parser (document.createElement('template').innerHTML// = string — see quarto-ojs-runtime.js's renderHtml/hypertext), which turns// onclick="..." into an inline handler compiled against global/window// scope. `viewState` is an OJS module binding, not a global, so every click// threw ReferenceError.//// Fix: the buttons below carry data-view/data-category-filter attributes// instead of onclick, and real addEventListener listeners are registered// on them here, inside this OJS cell, after the markup is parsed into a// live DOM node (querySelectorAll/addEventListener both work on a detached// DocumentFragment before it's inserted into the page). Because these// listeners are genuine closures created directly in this cell's own JS// source, `mutable view = ...` / `mutable categoryFilter = ...` inside them// are real, in-scope references to the mutable cells above — Observable's// parser resolves `mutable <name> = <expr>` wherever it appears in a cell's// source, and reassigning it re-runs every dependent cell (filteredBadges →// visibleBadges → the grid cell below).viewTabsPanel = {const node =html`${supportServices.isAuthenticated?` <div class="progress-section"> <h3>Your Badge Progress</h3> <div class="progress-stats"> <div class="progress-stat"> <div class="progress-stat-value">${badgeStats.earnedCount}/${badgeStats.totalCount}</div> <div class="progress-stat-label">Badges Earned</div> </div> <div class="progress-stat"> <div class="progress-stat-value">${badgeStats.completionPercent}%</div> <div class="progress-stat-label">Completion</div> </div> <div class="progress-stat"> <div class="progress-stat-value">${badgeStats.totalBadgeXP.toLocaleString()}</div> <div class="progress-stat-label">XP from Badges</div> </div>${badgeStats.categoryKeys.map(key =>` <div class="progress-stat"> <div class="progress-stat-value">${badgeStats.byCategory[key].earned}/${badgeStats.byCategory[key].total} </div> <div class="progress-stat-label">${badgeStats.byCategory[key].label} </div> </div> `).join('')} </div> <!-- View Tabs --> <div class="badge-tabs"> <button class="badge-tab ${view ==='all'?'active':''}" data-view="all"> All Badges (${badgeCatalog.length}) </button> <button class="badge-tab ${view ==='earned'?'active':''}" data-view="earned"> Earned (${badgeStats.earnedCount}) </button> <button class="badge-tab ${view ==='not-earned'?'active':''}" data-view="not-earned"> Still to Earn (${badgeCatalog.length- badgeStats.earnedCount}) </button> </div> <!-- Category Filters --> <div class="category-filter"> <button class="filter-btn ${categoryFilter ==='all'?'active':''}" data-category-filter="all"> All Categories </button>${badgeStats.categoryKeys.map(key =>` <button class="filter-btn ${categoryFilter === key ?'active':''}" data-category-filter="${key}">${badgeStats.byCategory[key].label} </button> `).join('')} </div> </div>`:''}`;// html`` returns null (not an empty fragment) when its content resolves// to nothing — e.g. every time this renders signed-out, since the ternary// above yields ''. Guard on node itself, not just the method lookup. node?.querySelectorAll('[data-view]').forEach((btn) => { btn.addEventListener('click', () => { mutable view = btn.dataset.view; }); }); node?.querySelectorAll('[data-category-filter]').forEach((btn) => { btn.addEventListener('click', () => { mutable categoryFilter = btn.dataset.categoryFilter; }); });return node;}
Show code
// Signed-in badge grid, populated at runtime from badge_definitions.html`${supportServices.isAuthenticated? ( visibleBadges.length===0?` <div class="badge-empty-state"> <div class="badge-empty-state-icon">${badgeIconSvg('award')}</div> <h3 class="badge-empty-state-title">Nothing here yet</h3> <p class="badge-empty-state-copy"> No badges match this view. Complete a chapter's knowledge checks to earn your first chapter badge, then browse the gallery below for what comes next. </p> </div>`:` <div class="badges-grid">${visibleBadges.map(badge => {const categoryClass =`category-${badge.category}`;return` <div class="badge-card ${badge.earned?'earned':'locked'}"> <div class="badge-emoji ${!badge.earned?'badge-locked-emoji':''}">${renderBadgeArt(badge)} </div> <h3 class="badge-name">${badge.name}</h3> <div class="badge-category ${categoryClass}" style="background: ${badge.categoryAccent}; color: white;">${badge.categoryLabel} </div> <div class="badge-xp-reward"> +${badge.xp_reward} XP </div> <p class="badge-criteria">${badge.description|| badge.earning_logic?.criteria|| badge.criteria||'Complete the requirements to earn this badge'} </p>${badge.earned?` <div class="badge-earned-date"> Earned: ${newDate(badge.earnedDate).toLocaleDateString('en-US', { year:'numeric',month:'long',day:'numeric' })} </div> `:` <div class="badge-card-status">${badgeIconSvg('lock')} Not earned yet </div> `} </div> `; }).join('')} </div>${filteredBadges.length> visibleBadges.length?` <p class="badge-card-status"> Showing ${visibleBadges.length} of ${filteredBadges.length} badges in this view. Narrow it with a category filter above. </p> `:''}`) :''}`
From One Chapter to the Crown
Four tiers, and each one is built out of the tier below it. Nothing is awarded for time spent or pages scrolled — every badge below is issued against work the platform can verify.
Chapter badge
Answer every knowledge check correctly and reach every required checkpoint in one chapter. Worth 20 XP per required knowledge check plus 30 XP per checkpoint, never less than 25. Shown: MQTT QoS Levels.
+60 XP · one of 983
Module badge
Earn the chapter badge for every assessable chapter in a module. Worth 200 XP plus 25 XP for each of those chapters. Shown: MQTT, which covers 13 chapters.
+525 XP · one of 41
Module mastery
Complete every chapter in the module, including the reading-only ones that carry no checks. The crown is deliberately harder to reach than the module badge beneath it.
+500 XP · one of 41
Path mastery
Reach module mastery in every module a learning path covers. Shown: the University IoT Curriculum crown, which spans 30 modules.
+1,500 XP · one of 5
Start With These
Three that are reachable in a single session: finish a chapter, finish a quiz, and ace one.
First Steps
Complete your first chapter.
+50 XP
Quiz Taker
Complete your first quiz.
+25 XP
Perfect Score
Score 100% on a quiz.
+100 XP
Chapter-Count Milestones
These four track raw distance covered, whichever chapters you pick. They arrive automatically as your completed-chapter count crosses each threshold.
Curious Learner
Complete 5 chapters.
+100 XP
Dedicated Student
Complete 10 chapters.
+200 XP
IoT Enthusiast
Complete 25 chapters.
+500 XP
IoT Expert
Complete 50 chapters.
+1,000 XP
Streaks
Your streak advances when you return on a new learning day. Come back after a gap and the current streak starts again at one — there is no freeze and no repair token. Your dashboard keeps both your current and your longest streak.
On Fire
Return on three consecutive learning days.
+50 XP
Week Warrior
Return on seven consecutive learning days.
+150 XP
Monthly Master
Return on thirty consecutive learning days.
+500 XP
Every Module Has a Badge
All 41 modules carry one, and the XP scales with how much ground the module covers: 200 XP plus 25 XP for every assessable chapter. Eight of the 41 are shown here.
MQTT
Earn the chapter badge for all 13 chapters in this module.
+525 XP
Sensors & Measurement
Earn the chapter badge for all 39 chapters in this module.
+1,175 XP
Electronics & Circuits
Earn the chapter badge for all 29 chapters in this module.
+925 XP
Security: Threats & Defense
Earn the chapter badge for all 24 chapters in this module.
+800 XP
LoRa & LoRaWAN
Earn the chapter badge for all 19 chapters in this module.
+675 XP
Edge & Fog Computing
Earn the chapter badge for all 20 chapters in this module.
+700 XP
Analytics & ML
Earn the chapter badge for all 51 chapters in this module.
+1,475 XP
Prototyping
Earn the chapter badge for all 27 chapters in this module.
+875 XP
Finish a Learning Path
Five role-based paths run through the curriculum, from the Young IoT Explorer route to the full University IoT Curriculum. Completing one earns its graduate badge.
Young IoT Explorer Graduate
Complete the Young IoT Explorer learning path.
+100 XP
High School IoT Foundations Graduate
Complete the High School IoT Foundations learning path.
+100 XP
University IoT Curriculum Graduate
Complete the University IoT Curriculum learning path.
+100 XP
IoT Professional Practitioner Graduate
Complete the IoT Professional Practitioner learning path.
+100 XP
Executive IoT Strategy Graduate
Complete the Executive IoT Strategy learning path.
+100 XP
The Crown Tier: Mastery
Mastery is the top of the system, and it is deliberately not a piece of paper you file away. A module badge asks you to clear every assessable chapter. Mastery asks for the whole module — including the reading-only chapters that carry no checks — which is why the crowned artwork is rarer than the badge underneath it.
It stays alive after you earn it. IoT practice moves, so chapters get revised; when content you already completed changes, your dashboard flags it, tells you what’s new, and completing it again pays a 20 XP knowledge refresh. Mastery records complete coverage at this point in an evolving curriculum. There is always more to earn.
41 module masteries — 500 XP each, for finishing every chapter in a module.
Actuators & Control
AMQP
Analytics & ML
Application Protocols
Applications & Use Cases
Authentication & Access
Bluetooth & BLE
Capstone & Resources
Cellular IoT
CoAP
Cryptography
Data Storage
Design Methodology
Design Patterns
Edge & Fog Computing
Electronics & Circuits
Emerging Paradigms
Energy & Power
IoT Fundamentals
Integration & Gateways
LoRa & LoRaWAN
MQTT
Network Topologies
Core Networking
Privacy & Compliance
Prototyping
Reference Architectures
RFID, NFC & UWB
Routing & RPL
Security: Threats & Defense
Sensor Applications
Sensors & Measurement
Specialized Architectures
Stream Processing
Testing & Validation
Transport Protocols
UX Design
Visualization
Wi-Fi & 802.11
Wireless Sensor Networks
Zigbee, Thread & Matter
5 path masteries — 1,500 XP each, awarded once every module a path covers has reached module mastery.
Young IoT Explorer
High School IoT Foundations
University IoT Curriculum
IoT Professional Practitioner
Executive IoT Strategy
XP and Levels, Briefly
XP is a running record, not a score you can lose. A new profile opens with a 50 XP welcome bonus, and after that XP comes only from completed work — knowledge checks, chapters, badges, streaks and mastery. Every source is recorded once, so the same achievement can never pay twice.
Your level follows the XP total on a widening curve: level 2 at 100 XP, level 3 at 400 XP, level 4 at 900 XP, level 10 at 8,100 XP. Each level costs more than the last, so a level number always means roughly the same amount of verified work.
Where the Full Rules Live
This page shows the artwork. Everything else — the exact XP value of each knowledge-check difficulty, how the duplicate guards work, what the freshness markers on your dashboard mean — is spelled out in Badges & Progress.
Your own totals, streak, completed chapters and earned badges all live above, in My progress.