← back to Wallco Ai
gamify: real game feel — XP, levels, combos, haptics, particles, achievements
70cab7c4de6a323dc697bc0ec6d2a6f25b2759d2 · 2026-05-11 23:55:42 -0700 · SteveStudio2
Steve's verdict on v1: 'gamified?? seemed lame'. Rebuilt with actual
game mechanics that you can feel on a phone.
NEW: public/js/wallco-game.js — loaded site-wide via FOOTER.
Single client-side engine. localStorage-backed. Pages call
WC.reward(action[, event]) on every action. ~13 KB total.
EIGHT LEVELS (xp threshold → name):
0 Browser · 50 Voter · 150 Critic · 350 Curator · 750 Connoisseur
· 1500 Tastemaker · 3000 Patron · 6000 Maestro
XP TABLE:
love +10 · super +25 · hot +10 · pickA/B +5 · skip +2 · not +2
COMBO MULTIPLIER (consecutive positive actions):
streak 3 → ×2 · streak 5 → ×3 · streak 10 → ×5
Breaking streak (NOT/SKIP) at ≥5 shows '💔 Streak broken' flash.
DAILY QUEST: 20 votes → +100 XP bonus + toast '🏆 Daily Quest complete'
TEN ACHIEVEMENTS (each fires toast + haptic + chord):
🎯 First Vote · ⚡ Speed Demon (10 in 60s) · 🔥 Streak ×5
🔥🔥 Streak ×10 · 🔥🔥🔥 Streak ×25 · 💯 Century (100 votes)
⭐ Super-fan (10 super-loves) · 🎨 Curator (level 3)
👑 Tastemaker (level 5) · 🏆 Daily Quest
FEEDBACK PER ACTION:
Particle burst — 6-14 hearts/stars/fire fly from tap point with
randomized angle + distance + rotation + scale.
Haptic — navigator.vibrate with action-specific pattern.
Audio — WebAudio synth tones (no asset load), action-specific
frequency + waveform; chord progressions on level-ups + streak
milestones + achievement unlocks.
XP flash — '+N XP ×multiplier' floats up from tap, fades.
Big-text flash — 'LEVEL UP · Voter' / '×3 STREAK' / '×5 ON FIRE'
pops in scale .5 → 1 → 1.4, center of screen.
STATS BANNER (#wc-game-banner) auto-fills on every game page:
Level · XP / next-XP · 🔥 streak · today · 🎯 Quest N/20
3px gradient progress bar across the bottom.
WIRED INTO:
/swipe commit('right') → WC.reward('love', event)
commit('up') → WC.reward('super', event)
commit('left') → WC.reward('skip', event)
/hot-or-not act('hot') → WC.reward('hot', event)
act('not') → WC.reward('not', event)
act('skip') → WC.reward('skip', event)
/poll vote(w, l) → WC.reward('pickA', event)
VERIFIED end-to-end on iPhone 14 viewport (390×844):
4 HOTs → Level 1 Voter, combo×2 fires at streak 3.
1 NOT → streak breaks visibly.
10 more HOTs → Level 2 Critic, 322 XP, 4 achievements unlock
(first_vote, speed_demon, streak_5, streak_10).
Banner shows 'Level 2 Critic · XP 322/350 · 🔥 10 streak ·
today 15 · 🎯 Quest 15/20' with progress bar.
Files touched
A public/js/wallco-game.js
Diff
commit 70cab7c4de6a323dc697bc0ec6d2a6f25b2759d2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 23:55:42 2026 -0700
gamify: real game feel — XP, levels, combos, haptics, particles, achievements
Steve's verdict on v1: 'gamified?? seemed lame'. Rebuilt with actual
game mechanics that you can feel on a phone.
NEW: public/js/wallco-game.js — loaded site-wide via FOOTER.
Single client-side engine. localStorage-backed. Pages call
WC.reward(action[, event]) on every action. ~13 KB total.
EIGHT LEVELS (xp threshold → name):
0 Browser · 50 Voter · 150 Critic · 350 Curator · 750 Connoisseur
· 1500 Tastemaker · 3000 Patron · 6000 Maestro
XP TABLE:
love +10 · super +25 · hot +10 · pickA/B +5 · skip +2 · not +2
COMBO MULTIPLIER (consecutive positive actions):
streak 3 → ×2 · streak 5 → ×3 · streak 10 → ×5
Breaking streak (NOT/SKIP) at ≥5 shows '💔 Streak broken' flash.
DAILY QUEST: 20 votes → +100 XP bonus + toast '🏆 Daily Quest complete'
TEN ACHIEVEMENTS (each fires toast + haptic + chord):
🎯 First Vote · ⚡ Speed Demon (10 in 60s) · 🔥 Streak ×5
🔥🔥 Streak ×10 · 🔥🔥🔥 Streak ×25 · 💯 Century (100 votes)
⭐ Super-fan (10 super-loves) · 🎨 Curator (level 3)
👑 Tastemaker (level 5) · 🏆 Daily Quest
FEEDBACK PER ACTION:
Particle burst — 6-14 hearts/stars/fire fly from tap point with
randomized angle + distance + rotation + scale.
Haptic — navigator.vibrate with action-specific pattern.
Audio — WebAudio synth tones (no asset load), action-specific
frequency + waveform; chord progressions on level-ups + streak
milestones + achievement unlocks.
XP flash — '+N XP ×multiplier' floats up from tap, fades.
Big-text flash — 'LEVEL UP · Voter' / '×3 STREAK' / '×5 ON FIRE'
pops in scale .5 → 1 → 1.4, center of screen.
STATS BANNER (#wc-game-banner) auto-fills on every game page:
Level · XP / next-XP · 🔥 streak · today · 🎯 Quest N/20
3px gradient progress bar across the bottom.
WIRED INTO:
/swipe commit('right') → WC.reward('love', event)
commit('up') → WC.reward('super', event)
commit('left') → WC.reward('skip', event)
/hot-or-not act('hot') → WC.reward('hot', event)
act('not') → WC.reward('not', event)
act('skip') → WC.reward('skip', event)
/poll vote(w, l) → WC.reward('pickA', event)
VERIFIED end-to-end on iPhone 14 viewport (390×844):
4 HOTs → Level 1 Voter, combo×2 fires at streak 3.
1 NOT → streak breaks visibly.
10 more HOTs → Level 2 Critic, 322 XP, 4 achievements unlock
(first_vote, speed_demon, streak_5, streak_10).
Banner shows 'Level 2 Critic · XP 322/350 · 🔥 10 streak ·
today 15 · 🎯 Quest 15/20' with progress bar.
---
public/js/wallco-game.js | 298 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 298 insertions(+)
diff --git a/public/js/wallco-game.js b/public/js/wallco-game.js
new file mode 100644
index 0000000..f0f420b
--- /dev/null
+++ b/public/js/wallco-game.js
@@ -0,0 +1,298 @@
+/* wallco-game.js — XP, levels, streaks, combos, daily quests, achievements,
+ * haptics, audio, particle bursts. Loaded site-wide via FOOTER. All
+ * client-side; localStorage-backed. Pages call WC.reward(action[, event]) on
+ * every user action.
+ *
+ * Actions and their XP base:
+ * love +10 (right-swipe, ♥ button, picked side in poll)
+ * super +25 (up-swipe, ★ button)
+ * hot +10 (HOT in hot-or-not, ★5 star vote)
+ * not +2 (NOT)
+ * skip +2 (SKIP, left-swipe)
+ * pickA/B +5 (poll pair vote)
+ *
+ * Combo multiplier: 3 in a row ×2, 5 ×3, 10 ×5
+ * Levels: Browser 0 / Voter 50 / Critic 150 / Curator 350 / Connoisseur 750
+ * / Tastemaker 1500 / Patron 3000 / Maestro 6000
+ * Daily quest: vote on 20 today → +100 XP bonus
+ */
+(function () {
+ if (window.WC) return;
+
+ var LEVELS = [
+ { xp: 0, name: 'Browser' },
+ { xp: 50, name: 'Voter' },
+ { xp: 150, name: 'Critic' },
+ { xp: 350, name: 'Curator' },
+ { xp: 750, name: 'Connoisseur' },
+ { xp: 1500, name: 'Tastemaker' },
+ { xp: 3000, name: 'Patron' },
+ { xp: 6000, name: 'Maestro' }
+ ];
+ var XP = { love: 10, super: 25, hot: 10, pickA: 5, pickB: 5, skip: 2, not: 2 };
+ var ACHIEVEMENTS = [
+ { id: 'first_vote', test: function (s) { return s.totalVotes >= 1; }, label: 'First Vote', icon: '🎯' },
+ { id: 'speed_demon', test: function (s) { return s.minute >= 10; }, label: 'Speed Demon · 10 in a minute', icon: '⚡' },
+ { id: 'streak_5', test: function (s) { return s.maxStreak >= 5; }, label: 'Hot Streak ×5', icon: '🔥' },
+ { id: 'streak_10', test: function (s) { return s.maxStreak >= 10; }, label: 'On Fire · streak ×10', icon: '🔥🔥' },
+ { id: 'streak_25', test: function (s) { return s.maxStreak >= 25; }, label: 'Inferno · streak ×25', icon: '🔥🔥🔥' },
+ { id: 'century', test: function (s) { return s.totalVotes >= 100; }, label: 'Century · 100 votes', icon: '💯' },
+ { id: 'super_lover', test: function (s) { return s.supers >= 10; }, label: 'Super-fan · 10 super-loves', icon: '⭐' },
+ { id: 'curator', test: function (s) { return s.level >= 3; }, label: 'Reached Curator', icon: '🎨' },
+ { id: 'tastemaker', test: function (s) { return s.level >= 5; }, label: 'Reached Tastemaker', icon: '👑' },
+ { id: 'daily_quest', test: function (s) { return s.questDone; }, label: 'Daily Quest Complete', icon: '🏆' }
+ ];
+
+ function load() { try { return JSON.parse(localStorage.getItem('wc-game') || '{}'); } catch (e) { return {}; } }
+ function save(s) { localStorage.setItem('wc-game', JSON.stringify(s)); }
+ function today() { return new Date().toISOString().slice(0, 10); }
+ function questGoal() { return 20; }
+ function questText() { return 'Vote on ' + questGoal() + ' designs today'; }
+
+ function state() {
+ var s = load();
+ if (!s.xp) s.xp = 0;
+ if (!s.totalVotes) s.totalVotes = 0;
+ if (!s.streak) s.streak = 0;
+ if (!s.maxStreak) s.maxStreak = 0;
+ if (!s.supers) s.supers = 0;
+ if (!s.unlocked) s.unlocked = {};
+ if (!s.day || s.day !== today()) {
+ s.todayVotes = 0;
+ s.questDone = false;
+ s.day = today();
+ }
+ if (!s.todayVotes) s.todayVotes = 0;
+ s.recent = (s.recent || []).filter(function (t) { return Date.now() - t < 60000; });
+ s.minute = s.recent.length;
+
+ var lvl = 0;
+ for (var i = LEVELS.length - 1; i >= 0; i--) {
+ if (s.xp >= LEVELS[i].xp) { lvl = i; break; }
+ }
+ s.level = lvl;
+ s.levelName = LEVELS[lvl].name;
+ s.nextXP = LEVELS[lvl + 1] ? LEVELS[lvl + 1].xp : null;
+ return s;
+ }
+
+ // Audio (WebAudio, no asset load)
+ var actx = null;
+ function tone(freq, dur, type) {
+ try {
+ actx = actx || new (window.AudioContext || window.webkitAudioContext)();
+ if (actx.state === 'suspended') actx.resume();
+ var o = actx.createOscillator(); var g = actx.createGain();
+ o.type = type || 'sine'; o.frequency.value = freq;
+ g.gain.value = 0.06;
+ g.gain.exponentialRampToValueAtTime(0.001, actx.currentTime + dur);
+ o.connect(g); g.connect(actx.destination);
+ o.start(); o.stop(actx.currentTime + dur);
+ } catch (e) {}
+ }
+ function chord(notes, dur) { notes.forEach(function (n) { tone(n, dur, 'triangle'); }); }
+ function buzz(pattern) { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch (e) {} }
+
+ function burst(x, y, count, ch, color) {
+ for (var i = 0; i < count; i++) {
+ (function () {
+ var el = document.createElement('span');
+ el.textContent = ch;
+ el.style.cssText =
+ 'position:fixed;left:' + x + 'px;top:' + y +
+ 'px;font-size:22px;pointer-events:none;z-index:9999;color:' + color +
+ ';will-change:transform,opacity;transition:transform 800ms cubic-bezier(.2,.7,.3,1), opacity 800ms ease;';
+ document.body.appendChild(el);
+ var angle = Math.random() * Math.PI * 2;
+ var dist = 60 + Math.random() * 100;
+ requestAnimationFrame(function () {
+ el.style.transform =
+ 'translate(' + (Math.cos(angle) * dist) + 'px,' +
+ (Math.sin(angle) * dist - 30) + 'px) rotate(' + (Math.random() * 720 - 360) +
+ 'deg) scale(' + (0.6 + Math.random() * 0.8) + ')';
+ el.style.opacity = '0';
+ });
+ setTimeout(function () { el.remove(); }, 850);
+ })();
+ }
+ }
+
+ function flash(text, color) {
+ var el = document.createElement('div');
+ el.textContent = text;
+ el.style.cssText =
+ 'position:fixed;left:50%;top:30%;transform:translate(-50%,-50%) scale(.5);' +
+ 'font:600 42px/1 -apple-system,system-ui,sans-serif;color:' + (color || '#d2b15c') +
+ ';text-shadow:0 4px 24px rgba(0,0,0,.4);pointer-events:none;z-index:9999;opacity:0;' +
+ 'transition:transform 360ms cubic-bezier(.2,1.6,.4,1), opacity 360ms ease;';
+ document.body.appendChild(el);
+ requestAnimationFrame(function () {
+ el.style.opacity = '1';
+ el.style.transform = 'translate(-50%,-50%) scale(1)';
+ });
+ setTimeout(function () {
+ el.style.opacity = '0';
+ el.style.transform = 'translate(-50%,-50%) scale(1.4) translateY(-30px)';
+ }, 700);
+ setTimeout(function () { el.remove(); }, 1100);
+ }
+
+ function toast(html, color) {
+ var el = document.createElement('div');
+ el.innerHTML = html;
+ el.style.cssText =
+ 'position:fixed;left:50%;bottom:84px;transform:translateX(-50%) translateY(40px);' +
+ 'background:' + (color || '#1a1816') + ';color:#f0eadc;padding:14px 22px;border-radius:30px;' +
+ 'font:600 14px -apple-system,system-ui,sans-serif;box-shadow:0 12px 36px rgba(0,0,0,.4);' +
+ 'z-index:9999;opacity:0;transition:transform 300ms cubic-bezier(.2,1.6,.4,1), opacity 280ms ease;' +
+ 'border:1px solid rgba(210,177,92,.5);max-width:90vw;text-align:center;';
+ document.body.appendChild(el);
+ requestAnimationFrame(function () {
+ el.style.opacity = '1';
+ el.style.transform = 'translateX(-50%) translateY(0)';
+ });
+ setTimeout(function () {
+ el.style.opacity = '0';
+ el.style.transform = 'translateX(-50%) translateY(40px)';
+ }, 2400);
+ setTimeout(function () { el.remove(); }, 2800);
+ }
+
+ function updateBanner() {
+ var b = document.getElementById('wc-game-banner');
+ if (!b) return;
+ var s = state();
+ var progress = s.nextXP
+ ? Math.round((s.xp - LEVELS[s.level].xp) / (s.nextXP - LEVELS[s.level].xp) * 100)
+ : 100;
+ var quest = s.todayVotes + '/' + questGoal();
+ b.innerHTML =
+ '<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:center;font-size:12px;color:#f0eadc">' +
+ '<span><span style="opacity:.6">Level</span> <b style="color:#d2b15c">' + s.level + ' ' + s.levelName + '</b></span>' +
+ '<span><span style="opacity:.6">XP</span> <b>' + s.xp + '</b>' + (s.nextXP ? ' <span style="opacity:.5">/ ' + s.nextXP + '</span>' : '') + '</span>' +
+ '<span style="opacity:.5">·</span>' +
+ '<span>🔥 <b>' + s.streak + '</b> streak</span>' +
+ '<span><span style="opacity:.6">today</span> <b>' + s.todayVotes + '</b></span>' +
+ '<span style="opacity:.5">·</span>' +
+ '<span title="' + questText() + '">🎯 Quest <b>' + quest + '</b>' + (s.questDone ? ' ✓' : '') + '</span>' +
+ '</div>' +
+ '<div style="height:3px;background:rgba(255,255,255,.1);border-radius:2px;margin-top:8px;overflow:hidden">' +
+ '<div style="height:100%;background:linear-gradient(90deg,#d2b15c,#e0bd64);width:' + progress + '%;transition:width 400ms ease"></div>' +
+ '</div>';
+ }
+
+ function checkAchievements(s) {
+ ACHIEVEMENTS.forEach(function (a) {
+ if (s.unlocked[a.id]) return;
+ if (a.test(s)) {
+ s.unlocked[a.id] = Date.now();
+ setTimeout(function () {
+ toast('<span style="font-size:20px;margin-right:8px">' + a.icon + '</span><span>' + a.label + '</span>');
+ buzz([60, 80, 60, 80, 200]);
+ chord([523, 659, 784], 0.18);
+ }, 200);
+ }
+ });
+ }
+
+ window.WC = {
+ state: state,
+ reward: function (action, originEvent) {
+ var prev = state();
+ var s = state();
+ var positive = (action === 'love' || action === 'super' || action === 'hot' || action === 'pickA' || action === 'pickB');
+ var xpGain = XP[action] || 0;
+ var multiplier = 1;
+
+ if (positive) {
+ s.streak += 1;
+ if (s.streak >= 10) multiplier = 5;
+ else if (s.streak >= 5) multiplier = 3;
+ else if (s.streak >= 3) multiplier = 2;
+ } else if (action !== 'pickA' && action !== 'pickB') {
+ if (s.streak >= 5) {
+ flash('💔 Streak broken', '#c44');
+ buzz([200, 60, 200]);
+ tone(180, 0.18, 'sawtooth');
+ }
+ s.streak = 0;
+ }
+ if (action === 'super') s.supers += 1;
+ if (s.streak > s.maxStreak) s.maxStreak = s.streak;
+
+ var totalXP = xpGain * multiplier;
+ s.xp += totalXP;
+ s.totalVotes += 1;
+ s.todayVotes += 1;
+ s.recent = (s.recent || []).concat([Date.now()]).filter(function (t) { return Date.now() - t < 60000; });
+ s.minute = s.recent.length;
+
+ var newLvl = 0;
+ for (var i = LEVELS.length - 1; i >= 0; i--) {
+ if (s.xp >= LEVELS[i].xp) { newLvl = i; break; }
+ }
+ var leveledUp = newLvl > prev.level;
+ s.level = newLvl;
+ s.levelName = LEVELS[newLvl].name;
+ s.nextXP = LEVELS[newLvl + 1] ? LEVELS[newLvl + 1].xp : null;
+
+ if (s.todayVotes >= questGoal() && !s.questDone) {
+ s.questDone = true;
+ s.xp += 100;
+ setTimeout(function () {
+ toast('🏆 Daily Quest complete · +100 XP', '#1a3a1a');
+ buzz([80, 60, 80, 60, 80, 60, 300]);
+ chord([523, 659, 784, 1047], 0.3);
+ }, 400);
+ }
+
+ var x = window.innerWidth / 2, y = window.innerHeight / 2;
+ if (originEvent && originEvent.clientX) { x = originEvent.clientX; y = originEvent.clientY; }
+
+ if (action === 'love' || action === 'pickA' || action === 'pickB') { burst(x, y, 8, '♥', '#d2b15c'); buzz(40); tone(660, 0.10); }
+ if (action === 'super') { burst(x, y, 14, '★', '#5aa'); buzz([50, 50, 100]); chord([523, 784, 1047], 0.18); }
+ if (action === 'hot') { burst(x, y, 6, '🔥', '#d2b15c'); buzz(35); tone(880, 0.08); }
+ if (action === 'not') { buzz(30); tone(280, 0.10, 'sawtooth'); }
+ if (action === 'skip') { buzz(15); tone(440, 0.05, 'square'); }
+
+ if (positive) {
+ if (s.streak === 3) { flash('×2 COMBO', '#d2b15c'); buzz([40, 40, 80]); chord([523, 659], 0.14); }
+ else if (s.streak === 5) { flash('×3 STREAK', '#d2b15c'); buzz([50, 50, 100]); chord([523, 659, 784], 0.18); }
+ else if (s.streak === 10) { flash('×5 ON FIRE', '#e76e3a'); buzz([60, 60, 60, 60, 200]); chord([523, 659, 784, 1047], 0.22); }
+ else if (s.streak === 25) { flash('×5 INFERNO', '#e76e3a'); buzz([100, 50, 100, 50, 100, 50, 300]); chord([523, 659, 784, 1047, 1319], 0.3); }
+ }
+
+ if (totalXP > 0) {
+ var xpEl = document.createElement('span');
+ xpEl.textContent = '+' + totalXP + ' XP' + (multiplier > 1 ? ' ×' + multiplier : '');
+ xpEl.style.cssText =
+ 'position:fixed;left:' + x + 'px;top:' + (y - 30) +
+ 'px;font:700 16px -apple-system,sans-serif;color:#d2b15c;pointer-events:none;z-index:9999;' +
+ 'transition:transform 700ms ease, opacity 700ms ease;text-shadow:0 2px 8px rgba(0,0,0,.4);';
+ document.body.appendChild(xpEl);
+ requestAnimationFrame(function () { xpEl.style.transform = 'translateY(-60px)'; xpEl.style.opacity = '0'; });
+ setTimeout(function () { xpEl.remove(); }, 700);
+ }
+
+ if (leveledUp) {
+ setTimeout(function () {
+ flash('LEVEL UP · ' + s.levelName, '#d2b15c');
+ buzz([80, 60, 80, 60, 80, 60, 80, 60, 300]);
+ chord([523, 659, 784, 1047, 1319], 0.35);
+ }, 250);
+ }
+
+ checkAchievements(s);
+ save(s);
+ updateBanner();
+ return s;
+ },
+ refreshBanner: updateBanner
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', updateBanner);
+ } else {
+ updateBanner();
+ }
+})();
← 3563074 wallco.ai · /api/studio/category-insights — fix products SQL
·
back to Wallco Ai
·
hot-or-not: standalone server on port 9794 — own pm2 process a7922f0 →