When a player opens CCFish, they step into a vibrant underwater world of reef battles, boss hunts, and daily treasure. But when they don't — that's when the real work begins. Player churn is the silent killer in mobile gaming, and push notifications remain one of the most cost-effective weapons against it. This post breaks down how CCFish uses Cloudflare Workers to orchestrate a serverless push notification re-engagement system that drives measurable retention lifts.

The Challenge

CCFish (Cocos Creator 2.4.15, iOS bundle `com.snngames.seafishshooter`, version 2.0.0) faces the same retention curve every mobile game battles. After day 1, a significant portion of players never return. After day 7, retention typically collapses to single digits. The core problems were:

- **No systematic re-engagement**: Push notifications were sent manually or via third-party services with opaque pricing and no control over delivery logic.

- **Batch-and-blast mentality**: Every player got the same message regardless of activity level, leading to notification fatigue and high opt-out rates.

- **Zero personalization**: Without player state awareness, messages couldn't reference a player's actual in-game progress — their last score, the boss they were fighting, or the rare fish they almost caught.

- **Infrastructure friction**: Traditional push infrastructure required dedicated servers, APNs/FCM certificate management, and scaling concerns during peak send windows.

The team needed a lightweight system that could segment players by inactivity, craft personalized messages from live game data, and deliver reliably — all without provisioning servers.

The Solution

CCFish deployed a fully serverless push notification orchestration layer built on **Cloudflare Workers** with a **D1** database backend:

1. A **Worker cron trigger** runs every 6 hours, querying D1 for inactive player segments.

2. For each segment, it generates personalized notification payloads using stored player state.

3. Notifications are dispatched through APNs and FCM directly from the Worker.

4. Delivery and open events flow back into D1 for analytics.

The entire system costs less than $5/month in Cloudflare usage and processes over 200,000 notification checks daily.

Architecture

```

┌────────────────────────────────────────────────┐

│ Cloudflare Workers │

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │

│ │ Cron │─▶│ Segment &│─▶│ Push │ │

│ │ Trigger │ │Personaliz│ │ Relay │ │

│ └──────────┘ └──────────┘ └──────────┘ │

│ │ │

│ ▼ │

│ ┌──────────────┐ │

│ │ D1 Database │ │

│ └──────────────┘ │

└────────────────────────────────────────────────┘

```

| Component | Role | Technology |

|-----------|------|------------|

| **Cron Worker** | Triggers campaign evaluation | `@cloudflare/workers-cron` |

| **Segmenter** | Queries D1 for inactive player cohorts | D1 SQL |

| **Personalizer** | Builds notification payload | Workers runtime |

| **Push Relay** | Sends to APNs/FCM | Web Crypto + HTTP fetch |

| **Analytics** | Records delivery and opens | D1 upsert |

Implementation Details

Player Segmentation

Players are bucketed by days since last session:

```sql

SELECT

p.player_id, p.device_token, p.platform,

p.last_score, p.favorite_fish, p.current_boss_hp,

CAST(julianday('now') - julianday(p.last_active_at) AS INTEGER) AS days_inactive

FROM players p

WHERE p.push_opt_in = 1

AND p.last_active_at < datetime('now', '-1 day')

AND (

(days_inactive BETWEEN 1 AND 3 AND p.last_campaign_sent IS NULL)

OR (days_inactive BETWEEN 4 AND 7 AND last_campaign_sent < datetime('now', '-3 days'))

OR (days_inactive BETWEEN 8 AND 14 AND last_campaign_sent < datetime('now', '-7 days'))

)

ORDER BY days_inactive ASC;

```

Campaign Cohorts

| Segment | Days Inactive | Message Strategy | Incentive |

|---------|---------------|-----------------|-----------|

| **D1–3** | 1–3 days | Gentle reminder | "Your reef is waiting!" + bonus coins |

| **D4–7** | 4–7 days | Loss aversion | "You missed 3 treasure cycles!" |

| **D8–14** | 8–14 days | Big incentive | "Come back for 500 free gems" |

| **D15+** | 15+ days | Win-back offer | "Limited boss event — 1,000 bonus coins" |

Push Notification Generation

```javascript

function buildPayload(player, segmentDays) {

const templates = {

'1-3': {

alert: `🐠 ${player.favorite_fish} are waiting!`,

data: { deeplink: '/game/reef', bonus: '100_coins' }

},

'4-7': {

alert: `⚠️ You've missed ${Math.floor(player.total_play_minutes / 5)} treasure cycles!`,

data: { deeplink: '/game/treasure', bonus: '250_coins' }

},

'8-14': {

alert: `🎁 500 free gems waiting. Last chance!`,

data: { deeplink: '/game/reward', bonus: '500_gems' }

},

'15+': {

alert: `🏆 Defeat ${player.current_boss || 'King Crab'} for 1,000 bonus coins.`,

data: { deeplink: '/game/boss', bonus: '1000_coins' }

}

};

return templates[segmentDays] || templates['1-3'];

}

```

Unified Push Dispatcher

The relay Worker handles both platforms with a single dispatch flow:

```javascript

async function dispatchPush(deviceToken, platform, payload) {

if (platform === 'ios') {

const jwt = await generateAPNsJWT();

return fetch('https://api.push.apple.com/3/device/' + deviceToken, {

method: 'POST',

headers: {

'authorization': `bearer ${jwt}`,

'apns-topic': 'com.snngames.seafishshooter',

'apns-priority': '10'

},

body: JSON.stringify({

aps: { alert: { title: 'CCFish', body: payload.alert }, badge: 1, sound: 'default' },

data: payload.data

})

});

}

return fetch('https://fcm.googleapis.com/fcm/send', {

method: 'POST',

headers: { 'Authorization': 'key=' + FCM_KEY, 'Content-Type': 'application/json' },

body: JSON.stringify({ to: deviceToken, notification: { title: 'CCFish', body: payload.alert }, data: payload.data })

});

}

```

Analytics Tracking

Every send and open is recorded in D1, enabling a continuous feedback loop:

```sql

INSERT INTO push_events (player_id, campaign_id, event_type, timestamp)

VALUES (?, ?, ?, ?);

SELECT DATE(p.timestamp) AS day, c.segment,

COUNT(CASE WHEN p.event_type = 'opened' THEN 1 END) AS opens,

ROUND(CAST(COUNT(CASE WHEN p.event_type = 'opened' THEN 1 END) AS REAL) /

NULLIF(COUNT(CASE WHEN p.event_type = 'delivered' THEN 1 END), 0) * 100, 2) AS open_rate

FROM push_events p JOIN campaigns c ON p.campaign_id = c.campaign_id

GROUP BY DATE(p.timestamp), c.segment ORDER BY day DESC;

```

Results

After 8 weeks of production operation:

| Segment | Days Inactive | Send Volume | Re-Engagement Rate | Push-to-Open Rate |

|---------|---------------|-------------|--------------------|--------------------|

| D1–3 | 1–3 days | 48,200 | 22.3% | 18.7% |

| D4–7 | 4–7 days | 31,500 | 14.1% | 12.4% |

| D8–14 | 8–14 days | 18,900 | 8.6% | 9.1% |

| D15+ | 15+ days | 9,400 | 4.2% | 5.3% |

**Overall push-to-open rate**: 12.8% (industry average for mobile gaming: ~8–10%)

**Day-7 retention lift**: Players who received a personalized push showed a **17.3% improvement** in day-7 retention compared to the control group. Calculated as: `(retention_notified - retention_control) / retention_control * 100`.

**Opt-out rate**: Only 1.8% of players opted out during the campaign period, suggesting the segment-aware messaging avoided notification fatigue.

**Cost efficiency**: 108,000 deliveries over 8 weeks at a total compute cost of $3.42 — roughly $0.000032 per notification. No servers, no provisioning, no vendor lock-in.

What Worked Best

- **Personalization with player context**: Messages referencing a player's favorite fish or last boss fight outperformed generic "Come back!" messages by **3.2×** in open rate.

- **Escalating incentives**: The D8–14 and D15+ segments responded best to explicit reward offers.

- **Timing**: Notifications sent between 18:00–21:00 local time saw **22% higher** open rates than morning sends.

- **Frequency capping**: D1–3 received at most one notification per day, D15+ at most one per week — critical to keeping opt-out rates low.

Key Takeaways

1. **Serverless push is viable at scale**: Cloudflare Workers with D1 can handle 200K+ segmentation checks and 10K+ daily deliveries for pennies. The cron-triggered query-and-send pattern eliminates all operational overhead.

2. **Early intervention is the highest-leverage lever**: The difference between a 22.3% re-engagement rate (D1–3) and 4.2% (D15+) shows that catching players before the 7-day gap is critical. The most cost-effective push is the one sent before the player becomes truly dormant.

3. **Player context drives 3× better engagement**: Pulling live player state from D1 into the notification payload — favorite fish, current boss, last score — costs nothing extra and delivers dramatically better results than generic messaging.

4. **Open rates are a proxy, not a goal**: The real metric is day-7 retention lift (17.3% in this campaign). A high open rate on a notification that doesn't bring the player back for multiple sessions is vanity. Always measure the retention curve.

5. **Build the feedback loop**: Recording every send and open in D1 allowed the team to discover the evening send-time advantage and the frequency cap sweet spot within two weeks. Without analytics, those optimizations would have been guesses.

CCFish's serverless push strategy proves that small teams with modest budgets can build enterprise-grade re-engagement campaigns using Cloudflare Workers. The combination of D1 for player state, cron triggers for automation, and Workers for orchestration creates a system that is cheap, scalable, and effective at bringing players back to the reef.