Skip to content

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, plus declining_session_length/early_logout_streak as of WO-BUILD-RETENTION-SIGNALS-WRITEBACK) plus economic_loss_streak as of e42d6c6e / WO-BUILD-RETENTION-SIGNALS-TRADE-SQL-INSERT (trading.py passes db into track_activity for trade_buy/trade_sell, which inserts PlayerActivity with credits_involved). Region.active_players_30d is no longer always-zero — login/logout writeback plus durable trade rows feed PlayerActivity; 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 per retention_service.py:59-71; trade-SQL honesty 2026-08-16 per e42d6c6e; re-verified 2026-08-21 vs origin/feat 46bce720 — 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.pyGET /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, PlayerAnalyticsSnapshot for 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_paused or is_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_idSector.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)