[object Object]

← 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

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 →