Retention¶
Status: 🚧 Partial — Weekly faction/personal-rep decay, the welcome-back turn bonus, and the nightly at-risk sweep are all scheduler-wired (
reputation_team_sweeps.py:_run_weekly_decay_sync,turn_service.py:welcome_back+Player.return_boost_until,presence_helpers.py:_run_retention_sweep_sync). All 7 at-risk signals now fire against live durable data: the prior six (dormant_session,lapsed,negative_combat_streak,social_isolation, plusdeclining_session_length/early_logout_streakas ofWO-BUILD-RETENTION-SIGNALS-WRITEBACK) pluseconomic_loss_streakas ofe42d6c6e/WO-BUILD-RETENTION-SIGNALS-TRADE-SQL-INSERT(trading.pypassesdbintotrack_activityfortrade_buy/trade_sell, which insertsPlayerActivitywithcredits_involved).Region.active_players_30dis no longer always-zero — login/logout writeback plus durable trade rows feedPlayerActivity; it still undercounts players whose only activity in the 30d window is event types that stay Redis-only (move/dock/combat/warp/etc., no SQL mirror). (impl audit 2026-07-11; corrected 2026-08-07 perretention_service.py:59-71; trade-SQL honesty 2026-08-16 pere42d6c6e; re-verified 2026-08-21 vs origin/feat46bce720— tip delta Admin Soft-ORDER #789/#788 honesty only; Soft-HOLD residuals unchanged.)
How the platform measures, predicts, and acts on player retention. The goal is to keep players engaged once they're past first-login, recover players who drift toward inactivity, and produce honest engagement metrics for operators and region owners.
This document is a target spec — it describes how the retention machinery should work end-to-end. Inline status (✅/🚧/📐) lives in FEATURES; here we describe target state.
Engagement metrics¶
Daily / Weekly / Monthly Active¶
The standard funnel:
| Metric | Definition | Window |
|---|---|---|
| DAU | Distinct players with ≥ 1 logged action in the last 24 hours | 24h |
| WAU | Distinct players with ≥ 1 logged action in the last 7 days | 7d |
| MAU | Distinct players with ≥ 1 logged action in the last 30 days | 30d |
| Stickiness | DAU ÷ MAU | derived |
A "logged action" is any event recorded by PlayerActivityService.track_activity — login, trade, combat, sector move, dock, planet land, warp.
Online now¶
PlayerActivityService.get_online_player_count returns the size of the activity:online_players Redis set. Membership is added on track_login, removed on track_logout, and TTL'd if the session goes silent.
Session metrics¶
Per session captured in Redis under activity:session:{player_id} with TTL 24h:
{
"login_at": "...",
"last_activity_at": "...",
"actions_count": int,
"trades_count": int,
"trade_volume": int,
"combat_events": int,
"sectors_visited": [...],
"session_duration_minutes": int (computed at logout)
}
AnalyticsService._calculate_average_session_time produces the rolling mean over a configurable window (default 7 days) and exposes it on the real-time analytics API (average_session_time). On tip b798faaa (PR #717 squash mergeCommit = 118cae0fdc9ec7f271022d98bad640a1c5294d80, LEG-386), Admin Player Analytics fetches that field into metrics and the Session Time card renders it as hours when non-null (PlayerAnalytics.tsx ~687–694) — the old stub "No session tracking yet" is gone. When the value is null, the card shows an em-dash plus "Session time unavailable" or "Analytics endpoint unavailable" (honesty demotion of the empty state, not a new formula). ✅ Shipped on tip for the Session Time metric card. Do not invent a session-time formula.
Retention rate¶
retention_rate(N days) =
(players whose created_at <= now - N days AND is_active = true)
÷
(players whose created_at <= now - N days)
AnalyticsService._calculate_retention_rate(days) provides 7-day and 30-day rolling rates and exposes them on the analytics API (player_retention_rate_7d / related fields). On tip 6f25007f (#690 MERGED squash @ 18:03Z; LEG-376 / LEG-434/#441), Admin Player Analytics fetches player_retention_rate from player_retention_rate_7d into metrics and the Retention Rate card renders the fetched rate as a percent when non-null (PlayerAnalytics.tsx ~702–706) — the old stub "No retention telemetry surfaced yet" is gone for this card. ✅ Shipped on tip for the Retention Rate metric card. ✅ Gameserver re-engagement queue tip-shipped on origin/feat b798faaa (PR #679 MERGED squash; admin_re_engagement.py — GET /api/v1/admin/re-engagement/summary, list OPEN queue, PATCH status). ✅ Admin Re-engagement OPEN queue tip-shipped on tip 1f6acc0e (land #772 / mergeCommit 3fbb6073; later #780 also on tip) — services/admin-ui/src/components/pages/ReEngagementQueuePanel.tsx mounted from PlayerAnalytics.tsx (import + render ~287; LEG-880). Do not invent a retention-rate formula.
Activity tracking pipeline¶
[Game action]
|
v
[PlayerActivityService.track_activity]
| (stores event in Redis)
v
[PlayerActivity table] <-- async writeback for durable analytics
|
v
[AnalyticsService aggregations]
|
v
[Admin dashboard / region governor reports]
Redis ↔ Postgres split¶
- Redis stores hot, ephemeral session and event data with TTL — fast reads, no schema migrations.
- Postgres stores
PlayerActivity,PlayerSession,PlayerAnalyticsSnapshotfor durable analytics, retention math, and per-player at-risk signal computation.
Writes happen first to Redis (low latency); a writeback job (target: every 5 minutes) drains recent events into Postgres for durable storage. On Redis loss the most-recent ~5 minutes of telemetry can be lost; live gameplay is unaffected.
Inactivity decay¶
Several systems decay when a player goes quiet. Each is independent.
ARIA relationship¶
Player.aria_relationship_score decays by 1 point per day inactive (apply_inactivity_decay in the ARIA service). Never falls below 0. Reset to current value on next login. See FEATURES/gameplay/aria-companion.md.
Faction reputation¶
FactionService.apply_reputation_decay runs per player periodically:
- Reputation above +100 or below −100 drifts toward neutral when last updated > 30 days ago.
- Drift rate: 1 point/day past the 30-day threshold.
- Maximum decay per call: 50 points.
- Never crosses the ±100 floor (decay stops at the neutral band).
- Skipped if
decay_pausedoris_locked.
Personal reputation¶
Weekly tick decays toward zero:
- score > 0: score -= 5 (floor 0).
- score < 0: score += 5 (ceiling 0).
Applied to all active players. See SYSTEMS/bounty-and-reputation.md.
Regional standing¶
RegionalMembership.reputation_score does not auto-decay by default. A region's owner can opt in to per-region decay rules (target spec).
Turn regeneration¶
Turns regenerate at a fixed rate regardless of activity (see SYSTEMS/turn-regeneration.md) — they accumulate up to a cap. Inactivity does not cost turns; it just leaves them maxed.
At-risk signals¶
Each at-risk signal is computed per player directly from the durable analytics tables (PlayerActivity, PlayerSession, PlayerAnalyticsSnapshot) — a threshold check on that player's own login, session, combat, economic, and social history. No cross-player aggregation or ML clustering is involved (per ADR-0016). The signals:
| Signal | Threshold | Meaning | Status |
|---|---|---|---|
dormant_session |
No login in 7+ days | Player is drifting | ✅ Live — reads Player.last_game_login |
lapsed |
No login in 30+ days | High churn risk | ✅ Live — reads Player.last_game_login |
declining_session_length |
Last 5 sessions trending down (>30% drop) | Engagement waning | ✅ Live as of WO-BUILD-RETENTION-SIGNALS-WRITEBACK — reads PlayerSession, now durably written at login/logout; needs 5 completed sessions to accumulate before it can trip |
early_logout_streak |
3 consecutive sessions < 5 minutes | Frustration / boredom | ✅ Live as of WO-BUILD-RETENTION-SIGNALS-WRITEBACK — reads PlayerSession, now durably written at login/logout |
negative_combat_streak |
Recent kill/death ratio inverted | Player getting farmed | ✅ Live — reads CombatLog |
economic_loss_streak |
Recent net credit loss > 50% of holdings | Trading struggles | ✅ Live as of e42d6c6e / WO-BUILD-RETENTION-SIGNALS-TRADE-SQL-INSERT — reads PlayerActivity; trading.py calls track_activity() for trade_buy/trade_sell with db=, which inserts durable rows with credits_involved (Redis session counters still update too) |
social_isolation |
Solo player with no team / messages in 14 days | Social hook missing | ✅ Live — reads Player.team_id + Message |
Signals are computed nightly; flagged players land in a re-engagement queue. All seven signals now have populated durable sources; remaining honesty debt is elsewhere (e.g. non-trade gameplay events that still lack a SQL PlayerActivity mirror — combat/move/dock/warp/etc.; see OPERATIONS/player-activity.md § Event types that remain Redis-only).
Re-engagement campaigns¶
The platform supports several re-engagement levers, applied based on signal severity.
Email / push reminder¶
For lapsed players, a templated email is queued (target spec — uses platform mail service, opt-in only). Frequency cap: at most one per 14 days. Ratified "go" — strictly opt-in, max 1 per 14 days, existing mail relay — by ADR-0093 item 41 (folded from ADR-0093, re-verified 2026-08-07).
In-game ARIA welcome-back¶
When a player returns after dormant_session, ARIA's first dialogue references the gap explicitly and summarises what changed:
"Welcome back, Captain. It's been 12 days. Two new sectors opened in your home cluster, and Equipment prices at Trade Hub Beta are at a 30-day low. Want a route?"
This is gated on aria_consciousness_level ≥ 2 (enough memories to summarise meaningfully).
Returning-player turn bonus¶
Player.turns is topped up by a "welcome back" bonus if last login was > 7 days ago. Bonus: min(500, days_inactive × 50). Capped to prevent abuse from alt accounts.
First-day-back rep boost¶
✅ Shipped — at-risk players who return after 7+ days inactive receive a one-day +1.5× emergent-rep multiplier (RETURN_BOOST_MULT = 1.5) on positive faction rep gains. Encourages re-engagement without overwhelming with hard objectives. The window is opened by turn_service.welcome_back alongside the turn bonus above (one return-detection drives both), setting Player.return_boost_until = now + 1 day; emergent_reputation_service.py reads it and multiplies positive apply_emergent_action deltas while the window is open. Per ADR-0032.
Seasonal events¶
Quarterly seasonal events (Galactic Trade Festival, Frontier Days, Founders' Anniversary) provide: - Time-limited rep multipliers on faction-aligned activities. - Increased reputation gain rates with all factions. - Special cosmetic rewards tied to login frequency during the event. - Region-wide buffs (decreased tax, free turn regeneration boost).
Events are scheduled on a fixed calendar; entry conditions are simple ("be logged in during the event window"). Rewards scale with consecutive-day participation.
Region-owner retention metrics¶
Region owners (see OPERATIONS/multi-regional.md) get a dashboard with their region's active_players_30d, total_trade_volume, and a simplified retention curve. This drives the regional governance feedback loop — owners who can't keep players engaged see it directly.
Region.active_players_30d is recomputed nightly from the underlying activity data. 🚧 The recompute job runs (economy_governance_sweeps.py Phase 4, WO-G18), and its COUNT(DISTINCT player_id) aggregate reads PlayerActivity joined through sector_id → Sector.region_id. Rows now include login/logout boundaries (WO-BUILD-RETENTION-SIGNALS-WRITEBACK) and durable trade_buy/trade_sell inserts (e42d6c6e) — so the old "trade without login is invisible" gap is closed. Residual undercount: players whose only activity in the 30d window is still-Redis-only event types (move/dock/combat/warp/etc.), or activity rows with sector_id NULL (they fail the region join).
Privacy¶
Activity tracking is OWASP-compliant:
- All event data is keyed by player_id, with explicit access controls.
- Players can request a data export of their activity history.
- Players can opt out of analytics (engagement events still need to fire for game state, but they are not aggregated for retention or analytics).
- IP addresses and user-agent strings are stored only for security logs (ARIASecurityLog), not retention analytics.
- Data retention: raw events 90 days, aggregated snapshots indefinitely (with no PII).
Source map¶
| Concern | Path (target) |
|---|---|
| Activity tracking (Redis layer) | services/gameserver/src/services/player_activity_service.py |
| At-risk signal computation (per-player thresholds, nightly) | services/gameserver/src/services/retention_service.py |
| Aggregate analytics | services/gameserver/src/services/analytics_service.py |
| Models | services/gameserver/src/models/player_analytics.py (PlayerSession, PlayerActivity, PlayerAnalyticsSnapshot) |
| ARIA inactivity decay | services/gameserver/src/services/aria_personal_intelligence_service.py:apply_inactivity_decay |
| Faction decay | services/gameserver/src/services/faction_service.py:apply_reputation_decay |
| Personal reputation decay | services/gameserver/src/services/personal_reputation_service.py:apply_weekly_decay |
| Region 30d active recompute | services/gameserver/src/services/regional_governance_service.py |
| Re-engagement scheduler | services/gameserver/src/services/retention_scheduler.py (target — not yet split out) |
Related¶
OPERATIONS/multi-regional.md— regional retention as a region-owner KPI.OPERATIONS/aria.md— ARIA's role in welcome-back interactions.SYSTEMS/turn-regeneration.md— turns are not coupled to retention but interact with returning-player bonuses.SYSTEMS/bounty-and-reputation.md— personal reputation decay.