Back to The Times of Claw

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

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>

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 →

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA