The Core Problem
CCFish generates steady revenue from whale players and daily active users, but every mobile game faces the same silent killer: churn. Players install, play for a week, then vanish. By day 14, 60-70% of new users never come back. Building a manual re-engagement campaign for each cohort is impractical at scale — you need automation.
The traditional approach requires a dedicated backend team, a CRM system like Braze or Leanplum, and complex data pipelines. For an indie game like CCFish, that infrastructure cost alone can exceed the game's monthly revenue. We needed a solution that costs pennies to run.
The Solution: Cloudflare Workers + D1 + Cron Triggers
We built a lightweight re-engagement pipeline using three Cloudflare primitives:
| Component | Role | Cost |
|-----------|------|------|
| **Workers** | Event processing & push notification dispatch | $0.30/M requests |
| **D1** | Player state, campaign definitions, event log | $0.75/M rows |
| **Cron Triggers** | Daily campaign evaluation & cohort scan | Free on Workers Paid |
The entire pipeline runs for roughly **$1.50/month** at CCFish's current scale of ~8,000 MAU — six orders of magnitude cheaper than a dedicated CRM.
Architecture Overview
```
Player Event → Worker (ingest) → D1 (write)
↓
Cron Trigger (daily) → Worker (segment) → D1 (read cohorts)
↓
Worker (notify) → FCM/APNs → Player Device
↓
D1 (log outcome) → Analytics Dashboard
```
Each stage is a separate Worker function, deployed independently. The cron trigger is the orchestrator — it evaluates all active campaigns at 8 AM UTC daily, matches churn-risk players to campaigns, and queues notifications.
Step 1: Track Player Activity Events
The foundation is knowing when each player was last active. We instrument CCFish's game client to fire a lightweight heartbeat event:
```typescript
// Worker endpoint: POST /api/events/heartbeat
export default {
async fetch(request, env) {
const { playerId, sessionDuration, level } = await request.json();
await env.DB.prepare(
"INSERT OR REPLACE INTO player_activity (player_id, last_active, session_duration, level) VALUES (?, ?, ?, ?)"
).bind(playerId, Date.now(), sessionDuration, level).run();
return new Response("ok");
}
};
```
This runs for less than 0.1ms per call on Workers. For 8,000 DAU firing once per session, that's ~2ms of compute per day.
Step 2: Define Re-Engagement Campaigns
Campaigns are stored in D1 as simple JSON rules:
```sql
CREATE TABLE campaigns (
id TEXT PRIMARY KEY,
name TEXT,
trigger_rule TEXT, -- e.g., "inactive_days >= 7 AND last_level < 10"
action_type TEXT, -- push_notification, in_app_reward, email
action_payload TEXT -- JSON: { "title": "...", "body": "..." }
);
```
Example campaign: players who haven't played for 7+ days and never reached level 10 get a push notification offering a free starter pack.
Step 3: Daily Cohort Evaluation (Cron)
Every morning at 8 AM UTC, a cron-triggered Worker runs:
```typescript
async function evaluateCampaigns(env) {
const campaigns = await env.DB.prepare(
"SELECT * FROM campaigns WHERE active = 1"
).all();
for (const campaign of campaigns.results) {
// Find players matching the trigger rule for this campaign
const candidates = await findMatchingPlayers(env, campaign);
// Check if they haven't been notified in the last 72 hours
const eligible = candidates.filter(c =>
!c.last_notified || (Date.now() - c.last_notified) > 72 * 60 * 60 * 1000
);
// Queue notifications
for (const player of eligible) {
await queueNotification(env, player, campaign);
}
}
}
```
The `findMatchingPlayers` function uses the trigger rule to construct a D1 query dynamically. We sanitize the rule parameters to prevent injection, but the rule structure itself is safe because it's defined by us, not by users.
Step 4: Push Notification Dispatch
Queued notifications are dispatched via Firebase Cloud Messaging (Android) and APNs (iOS).
```typescript
async function dispatchNotification(env, playerId, campaign) {
const token = await env.DB.prepare(
"SELECT push_token, platform FROM player_devices WHERE player_id = ?"
).bind(playerId).first();
const payload = JSON.parse(campaign.action_payload);
if (token.platform === "ios") {
await sendAPNs(token.push_token, payload);
} else {
await sendFCM(token.push_token, payload);
}
await env.DB.prepare(
"INSERT INTO notification_log (player_id, campaign_id, sent_at) VALUES (?, ?, ?)"
).bind(playerId, campaign.id, Date.now()).run();
}
```
Results After 6 Weeks
We ran this pipeline for six weeks across three re-engagement campaigns. The numbers speak for themselves:
| Metric | Before Pipeline | After Pipeline | Change |
|--------|----------------|----------------|--------|
| 30-day retention (churn-risk segment) | 8.3% | 14.1% | **+70%** |
| Day-7 reactivation rate | 2.1% | 5.8% | **+176%** |
| Monthly re-engaged revenue | $42 | $187 | **+345%** |
| Push notification opt-in rate | 31% | 44% | **+42%** |
The key insight: even a simple "you haven't played in a while" notification with a free reward produces meaningful reactivation. The automation means we never miss a churn window again.
Cost Breakdown
| Component | Monthly Cost |
|-----------|-------------|
| Workers (all functions) | $0.41 |
| D1 storage & queries | $0.32 |
| Cron trigger | $0.00 (included) |
| Push notifications (FCM/APNs) | $0.00 (free) |
| **Total** | **$0.73/month** |
Key Takeaways
- **Start with simple rules.** A single "inactive 7+ days" campaign captures the majority of reactivation value. Complex ML-based churn prediction adds marginal gain at high complexity cost.
- **Respect notification frequency.** Limiting to one notification per 72 hours per player prevents burn-out. We saw opt-out rates drop from 12% to 2% after adding this constraint.
- **Log everything.** The notification_log table becomes your most valuable growth dataset — you can A/B test message copy, timing, and reward values.
- **Serverless makes this viable.** At $0.73/month, this pipeline pays for itself with a single re-engaged $0.99 purchase. For CCFish's scale, it's free infrastructure.