Building a CRM Productivity Game with p5.js

Build a gamified CRM productivity game in DenchClaw using p5.js — earn points for completing follow-ups, advancing deals, and hitting daily goals.

Mark Rachapoom
Mark Rachapoom
·7 min read
Building a CRM Productivity Game with p5.js

Sales motivation is a real problem. Staring at a CRM table and grinding through follow-ups isn't inherently fun. But what if completing a follow-up spawned a particle burst? What if advancing a deal to "Closed Won" triggered an animation? What if your daily call goal was a health bar depleting in real time?

DenchClaw's App Builder lets you put p5.js inside a .dench.app folder with direct access to your CRM data. This guide builds a lightweight productivity game where your CRM actions earn you in-game points and visual rewards.

What You're Building#

  • A p5.js canvas game that reads your CRM activity
  • Points system: +5 for logging a follow-up, +20 for advancing a deal, +50 for closing a deal
  • Daily progress bar toward a configurable goal
  • Particle effects triggered by CRM events
  • Leaderboard if multiple reps use the same DenchClaw instance

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/crm-game.dench.app

.dench.yaml:

name: CRM Game
description: Gamified CRM activity tracker with p5.js rewards
icon: zap
version: 1.0.0
permissions:
  - read:crm
display: tab

Step 2: HTML with p5.js#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>CRM Game</title>
  <script src="https://cdn.jsdelivr.net/npm/p5@1.9.0/lib/p5.min.js"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #0f172a; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; min-height: 100vh; padding: 20px; font-family: system-ui, sans-serif; }
    #stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; width: 100%; max-width: 800px; margin-bottom: 20px; }
    .stat { background: #1e293b; border-radius: 12px; padding: 16px; text-align: center; }
    .stat-value { font-size: 32px; font-weight: 800; color: #e2e8f0; }
    .stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; margin-top: 4px; }
    #gameCanvas { border-radius: 12px; overflow: hidden; }
    #activityLog { width: 100%; max-width: 800px; margin-top: 16px; background: #1e293b; border-radius: 12px; padding: 16px; max-height: 150px; overflow-y: auto; }
    .log-item { font-size: 12px; color: #94a3b8; padding: 3px 0; border-bottom: 1px solid #334155; }
    .log-item.big-win { color: #10b981; font-weight: 700; }
  </style>
</head>
<body>
  <div id="stats">
    <div class="stat"><div class="stat-value" id="todayScore">0</div><div class="stat-label">Today's Points</div></div>
    <div class="stat"><div class="stat-value" id="streak">0</div><div class="stat-label">Day Streak</div></div>
    <div class="stat"><div class="stat-value" id="followups">0</div><div class="stat-label">Follow-ups Today</div></div>
    <div class="stat"><div class="stat-value" id="dealsWon">0</div><div class="stat-label">Deals Won</div></div>
  </div>
  <canvas id="gameCanvas"></canvas>
  <div id="activityLog"></div>
  <script src="game.js"></script>
</body>
</html>

Talk to Sales

Tell us about your team and we'll show you how Dench fits your workflow.

By submitting this form you agree to be contacted by our team. See our Privacy Policy and Terms of Service.

Step 3: p5.js Game Logic#

game.js:

const DAILY_GOAL = 200; // points
const CANVAS_W = 800;
const CANVAS_H = 300;
 
let todayScore = 0;
let particles = [];
let goalReached = false;
let stars = [];
let activityData = {};
 
// p5.js sketch
const sketch = (p) => {
  p.setup = function() {
    const canvas = p.createCanvas(CANVAS_W, CANVAS_H);
    canvas.parent('gameCanvas');
    // Create background stars
    for (let i = 0; i < 80; i++) {
      stars.push({ x: p.random(CANVAS_W), y: p.random(CANVAS_H), size: p.random(1, 3), twinkle: p.random(1000) });
    }
  };
 
  p.draw = function() {
    p.background(15, 23, 42);
 
    // Draw stars
    stars.forEach(star => {
      const brightness = 128 + 64 * Math.sin((p.millis() + star.twinkle) / 800);
      p.fill(brightness, brightness, brightness + 40, brightness);
      p.noStroke();
      p.ellipse(star.x, star.y, star.size);
    });
 
    // Progress bar background
    p.fill(30, 41, 59);
    p.noStroke();
    p.rect(40, CANVAS_H - 60, CANVAS_W - 80, 24, 12);
 
    // Progress fill
    const progress = Math.min(todayScore / DAILY_GOAL, 1);
    const fillWidth = (CANVAS_W - 80) * progress;
    
    if (progress >= 1) {
      // Rainbow gradient when goal reached
      for (let i = 0; i < fillWidth; i++) {
        const hue = (i / fillWidth * 360 + p.millis() / 10) % 360;
        p.colorMode(p.HSB);
        p.fill(hue, 80, 90);
        p.rect(40 + i, CANVAS_H - 60, 1, 24, 0);
      }
      p.colorMode(p.RGB);
    } else {
      p.fill(99, 102, 241);
      p.rect(40, CANVAS_H - 60, fillWidth, 24, 12, 0, 0, fillWidth < 24 ? 12 : 0);
    }
 
    // Progress label
    p.fill(148, 163, 184);
    p.noStroke();
    p.textSize(12);
    p.textAlign(p.LEFT);
    p.text(`Daily Goal: ${todayScore} / ${DAILY_GOAL} pts`, 40, CANVAS_H - 70);
 
    // Score in center of bar
    p.fill(255);
    p.textAlign(p.CENTER);
    p.textSize(12);
    p.text(`${Math.round(progress * 100)}%`, CANVAS_W / 2, CANVAS_H - 43);
 
    // Draw and update particles
    for (let i = particles.length - 1; i >= 0; i--) {
      const pt = particles[i];
      pt.y -= pt.vy;
      pt.x += pt.vx;
      pt.life -= 2;
      pt.vy -= 0.05; // gravity
 
      p.fill(pt.r, pt.g, pt.b, pt.life);
      p.noStroke();
      p.ellipse(pt.x, pt.y, pt.size * (pt.life / 255));
 
      if (pt.life <= 0) particles.splice(i, 1);
    }
 
    // Achievement text
    if (goalReached) {
      p.fill(16, 185, 129);
      p.textAlign(p.CENTER);
      p.textSize(28);
      p.text('🎯 DAILY GOAL REACHED!', CANVAS_W / 2, 80);
    }
  };
};
 
function spawnParticles(x, y, color, count = 20) {
  const [r, g, b] = color;
  for (let i = 0; i < count; i++) {
    particles.push({
      x, y,
      vx: (Math.random() - 0.5) * 4,
      vy: Math.random() * 3 + 2,
      size: Math.random() * 8 + 4,
      r, g, b,
      life: 255
    });
  }
}
 
function addLog(message, isBigWin = false) {
  const log = document.getElementById('activityLog');
  const item = document.createElement('div');
  item.className = `log-item${isBigWin ? ' big-win' : ''}`;
  item.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
  log.insertBefore(item, log.firstChild);
  if (log.children.length > 50) log.removeChild(log.lastChild);
}
 
function awardPoints(points, reason, x = CANVAS_W / 2, y = CANVAS_H / 2, isBigWin = false) {
  todayScore += points;
  document.getElementById('todayScore').textContent = todayScore;
  addLog(`+${points} pts — ${reason}`, isBigWin);
  
  const color = isBigWin ? [16, 185, 129] : [99, 102, 241];
  spawnParticles(x, y, color, isBigWin ? 50 : 20);
 
  if (todayScore >= DAILY_GOAL && !goalReached) {
    goalReached = true;
    spawnParticles(CANVAS_W / 2, CANVAS_H / 2, [251, 191, 36], 100);
    addLog('🎯 DAILY GOAL REACHED! Amazing work!', true);
  }
}
 
async function loadTodayActivity() {
  const today = new Date().toISOString().split('T')[0];
  
  // Count today's follow-ups (entries updated today)
  const followups = await dench.db.query(`
    SELECT COUNT(*) as count FROM entries
    WHERE DATE(updated_at) = '${today}'
    AND object_id IN (SELECT id FROM objects WHERE name = 'people')
  `);
  const followupCount = followups[0]?.count || 0;
  document.getElementById('followups').textContent = followupCount;
  
  // Count deals closed this month
  const wonDeals = await dench.db.query(`
    SELECT COUNT(*) as count FROM v_deals WHERE "Stage" = 'Closed Won'
    AND DATE("Close Date") >= DATE_TRUNC('month', CURRENT_DATE)
  `);
  document.getElementById('dealsWon').textContent = wonDeals[0]?.count || 0;
 
  // Award initial points for today's work
  const baseScore = followupCount * 5;
  if (baseScore > 0) {
    todayScore = baseScore;
    document.getElementById('todayScore').textContent = todayScore;
    addLog(`Loaded: ${followupCount} follow-ups completed today (+${baseScore} pts)`);
  }
 
  // Load streak from store
  const streak = await dench.store.get('activity_streak') || 0;
  document.getElementById('streak').textContent = streak;
}
 
// Listen for real-time CRM events to award points
dench.events.on('entry:updated', async (event) => {
  awardPoints(5, 'Contact updated');
});
 
dench.events.on('entry:created', async (event) => {
  awardPoints(10, 'New contact/deal added');
});
 
// Initialize p5 and load data
new p5(sketch);
loadTodayActivity();

Extending the Game#

Achievements: Add milestone achievements like "First deal this week" or "5 follow-ups in one day" that trigger special animations.

Team leaderboard: If multiple reps use DenchClaw, query each rep's activity and display a ranked leaderboard.

Weekly challenges: Use dench.cron to reset weekly goals and surface new challenges each Monday.

Frequently Asked Questions#

How do I customize the daily goal?#

Change the DAILY_GOAL constant at the top of game.js. You can also make it configurable via dench.store: const DAILY_GOAL = await dench.store.get('daily_goal') || 200.

Can I track specific actions like calls or emails?#

Yes. If you have a custom "Activity" object in your CRM that logs calls and emails, query it and award points per activity type with different point values.

Does this work on mobile?#

p5.js canvas works on mobile. The main limitation is touch input — if you add interactive elements, test them on mobile. For a widget version, show just the progress bar without the full p5 canvas.

Can multiple team members use this simultaneously?#

Yes. Each user's DenchClaw instance has its own score state. For shared team scores, store totals in a shared DuckDB table and aggregate them in the leaderboard query.

Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →

Related articles

Keep reading

View all