Game Telemetry Pipeline: Player Analytics ve společnosti Scala
Moderní hra pro více hráčů generuje každý den miliardy událostí: hráč se pohybuje, provádí útok, koupit předmět, opustit hru. Fortnite překonává 350 milionů registrovaných hráčů a generuje desítky terabajtů dat za den. League of Legends shromažďuje přes 100 proměnných za dokončený zápas. Volejte Služba Duty zaznamenává každou vystřelenou kulku, každou smrt, každý snímek, kde latence překročí kritický práh.
Toto obrovské množství dat není hlukem pozadí: je to nervový systém hry. A telemetrie k odhalení proč hráči opouštějí po třetí úrovni, která zbraň je příliš silná v meta, která oblast serveru trpí zpožděním během špičky a jak dlouho trvá, než segment uživatelů přestane platit. Bez robustního telemetrického potrubí jedete se zavázanýma očima rychlostí 200 km/h.
V tomto článku jeden postavíme telemetrická hra průmyslového potrubí end-to-end: ze sbírky událostí na straně klienta a serveru, příjem pomocí Apache Kafka, zpracování v reálném čase pomocí Apache Flink, přes datový sklad pro historickou analýzu a řídicí panel pro rozhodnutí LiveOps. Také uvidíme jak implementovat segmentace hráčů e la předpověď churn přímo na potrubí.
Co se naučíte
- Taxonomie herních událostí: hráčské události, herní události, ekonomické události, systémové události
- Architektura potrubí: SDK klienta, sběrnice událostí, zpracování datových proudů, datový sklad
- Implementace pomocí Kafka, Flink a ClickHouse pro analýzu v reálném čase
- Schéma zpráv s Avro a Schema Registry pro ověření
- Segmentace hráčů RFM (aktuálnost, frekvence, peněžní) v reálném čase
- Predikce odlivu s inženýrstvím funkcí na potrubí Flink
- Analýza cesty a metriky udržení pro rozhodnutí LiveOps
- Kvalita dat, deduplikace a zpracování pozdních událostí
1. Taxonomie herních událostí
Než si postavíte potrubí, musíte vědět, co sbíráte. Herní události jsou rozděleny do čtyř hlavní kategorie, každá s jinou frekvencí, prioritou a metodami sběru.
| Kategorie | Příklady | Frekvence | Přednost | Udržení |
|---|---|---|---|---|
| Události hráčů | login, logout, session_start, level_up, achievement | Nízká (1–10/h) | Vysoký | Navždy |
| Herní události | kill, death, match_start, schopnost_used, zone_enter | Vysoká (100-1000/min) | Průměrný | 90 dní |
| Ekonomické akce | nákup, item_grant, currency_earn, shop_open | Nízká (0–5/h) | Kritika | Navždy |
| Systémové události | fps_drop, packet_loss, reconnect, crash_report | Střední (10–100/min) | Vysoký | 30 dní |
| Společenské akce | friend_add, party_join, chat_sent, report_player | Nízká (0–20/h) | Průměrný | 180 dní |
Každá událost musí mít společnou strukturu (obálku) se standardními poli: event_id,
event_type, player_id, session_id, server_timestamp,
client_timestampa typově specifické užitečné zatížení. Přísné a základní schéma pro
následná kvalita dat.
// Schema base evento telemetria (TypeScript/protobuf-like)
interface TelemetryEvent {
// Envelope comune a tutti gli eventi
event_id: string; // UUID v4 per deduplicazione
event_type: string; // "player.level_up", "gameplay.kill", etc.
schema_version: string; // "1.2.0" per compatibilità
// Identita
player_id: string; // ID univoco giocatore
session_id: string; // ID sessione corrente
game_build: string; // "2024.12.1" versione client
// Timestamp dual (client + server)
client_ts: number; // Unix ms dal client (non fidarsi ciecamente)
server_ts: number; // Unix ms dal server (source of truth)
client_tz: string; // "Europe/Rome" per analytics geo
// Contesto
platform: "pc" | "console" | "mobile";
region: string; // "eu-west-1"
match_id?: string; // Se in una partita
// Payload specifico del tipo
payload: Record<string, unknown>;
}
// Esempio evento kill
const killEvent: TelemetryEvent = {
event_id: "a3f7e2d1-8c4b-4e9a-b1f2-3d5e7c9a1b2c",
event_type: "gameplay.kill",
schema_version: "1.2.0",
player_id: "player_12345",
session_id: "sess_abcdef",
game_build: "2024.12.1",
client_ts: 1703123456789,
server_ts: 1703123456812,
client_tz: "Europe/Rome",
platform: "pc",
region: "eu-west-1",
match_id: "match_789xyz",
payload: {
victim_id: "player_67890",
weapon: "assault_rifle",
headshot: true,
distance_meters: 47.3,
position: { x: 145.2, y: 89.1, z: 12.0 },
ttk_ms: 320
}
};
2. Architektura potrubí: od klienta k řídicímu panelu
Architektura průmyslového herního telemetrického potrubí sleduje vzor Architektura Lambda nebo v modernější verzi Architektura Kappa kde vrstva zpracování jediného proudu slouží jak analýze v reálném čase, tak historické analýze.
Součásti potrubí
| Vrstvy | Komponent | Technologie | Role |
|---|---|---|---|
| Sbírka | SDK klienta | TypeScript/C++/Unity | Ukládání do vyrovnávací paměti, dávkování, opakování |
| Sbírka | Telemetrická brána | Go/Envoy | Authn, omezení rychlosti, fan-out |
| Požití | Event Bus | Apache Kafka | Trvanlivost, opakovatelnost, řazení |
| Zpracování | Stream Engine | Apache Flink | Analýza v reálném čase, obohacení |
| Porce | Databáze OLAP | ClickHouse | Rychlé dotazy v miliardách řádků |
| Porce | Hot Store | Redis | Metriky v reálném čase pro LiveOps |
| Vizualizace | Řídicí panely | Grafana/Metabase | Ovládací panel a analytika LiveOps |
3. Client SDK: Buffering a Batching
Odeslání každé události jednotlivě na server je klasickým selháním, které způsobuje obrovské zatížení sítě. Dobrý klient SDK implementuje lokální ukládání do vyrovnávací paměti e odeslání dávky: události jsou akumulovány ve vyrovnávací paměti v paměti a odesílány pravidelně nebo když vyrovnávací paměť dosáhne určité hodnoty velikost. V případě selhání sítě SDK používá a vyrovnávací paměť disku abych nezmeškal události.
// Client SDK di telemetria in TypeScript
class TelemetrySDK {
private buffer: TelemetryEvent[] = [];
private diskBuffer: DiskQueue;
private readonly BATCH_SIZE = 100;
private readonly FLUSH_INTERVAL_MS = 5000;
private readonly MAX_RETRY_ATTEMPTS = 3;
constructor(private config: SDKConfig) {
this.diskBuffer = new DiskQueue('telemetry_offline');
this.startFlushLoop();
this.startOfflineRetryLoop();
}
// Registra un evento nel buffer in-memory
track(eventType: string, payload: Record<string, unknown>): void {
const event: TelemetryEvent = {
event_id: crypto.randomUUID(),
event_type: eventType,
schema_version: "1.2.0",
player_id: this.config.playerId,
session_id: this.config.sessionId,
game_build: this.config.gameBuild,
client_ts: Date.now(),
server_ts: 0, // Valorizzato dal server
client_tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
platform: this.config.platform,
region: this.config.region,
payload
};
this.buffer.push(event);
if (this.buffer.length >= this.BATCH_SIZE) {
this.flush(); // Flush immediato se buffer pieno
}
}
private async flush(): Promise<void> {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.BATCH_SIZE);
try {
await this.sendBatch(batch);
} catch (error) {
// Fallback su disk buffer per invio offline
console.warn('Telemetry send failed, persisting to disk', error);
this.diskBuffer.enqueue(batch);
}
}
private async sendBatch(events: TelemetryEvent[]): Promise<void> {
const response = await fetch(this.config.gatewayUrl + '/v1/events', {
method: 'POST',
headers: {
'Content-Type': 'application/x-ndjson',
'X-Game-Build': this.config.gameBuild,
'Authorization': `Bearer ${this.config.apiKey}`
},
// NDJSON: una riga per evento, più efficiente di JSON array
body: events.map(e => JSON.stringify(e)).join('\n'),
signal: AbortSignal.timeout(5000) // 5s timeout
});
if (!response.ok) {
throw new Error(`Gateway error: ${response.status}`);
}
}
private startFlushLoop(): void {
setInterval(() => this.flush(), this.FLUSH_INTERVAL_MS);
}
private startOfflineRetryLoop(): void {
setInterval(async () => {
const pendingBatch = await this.diskBuffer.dequeue(this.BATCH_SIZE);
if (pendingBatch.length > 0) {
try {
await this.sendBatch(pendingBatch);
} catch {
this.diskBuffer.requeue(pendingBatch);
}
}
}, 30_000); // Retry ogni 30s
}
}
4. Kafka Topics and Schema Registry
Srdcem potrubí je Apache Kafka. Události jsou publikovány na samostatná témata podle kategorií, umožňující spotřebitelům registrovat se pouze na typy akcí, které je zajímají. The Registr schémat Confluent zajišťuje, že každá zpráva respektuje definované Avro schéma a blokuje chybné zprávy než kontaminují datový sklad.
# Schema Avro per TelemetryEvent (Avro IDL)
{
"type": "record",
"name": "TelemetryEvent",
"namespace": "io.gamestudio.telemetry",
"fields": [
{ "name": "event_id", "type": "string" },
{ "name": "event_type", "type": "string" },
{ "name": "schema_version", "type": "string" },
{ "name": "player_id", "type": "string" },
{ "name": "session_id", "type": "string" },
{ "name": "game_build", "type": "string" },
{ "name": "client_ts", "type": "long", "logicalType": "timestamp-millis" },
{ "name": "server_ts", "type": "long", "logicalType": "timestamp-millis" },
{ "name": "platform", "type": { "type": "enum", "name": "Platform",
"symbols": ["pc", "console", "mobile"] } },
{ "name": "region", "type": "string" },
{ "name": "match_id", "type": ["null", "string"], "default": null },
{ "name": "payload", "type": { "type": "map", "values": "string" } }
]
}
# Configurazione Kafka topics con partitioning
# Topic per categoria, partitionato per player_id (ordering per player)
kafka-topics.sh --create \
--bootstrap-server kafka:9092 \
--topic game.player.events \
--partitions 32 \
--replication-factor 3 \
--config retention.ms=2592000000 # 30 giorni
--config compression.type=lz4 # 60% riduzione size
--config segment.ms=3600000 # Roll segment ogni ora
kafka-topics.sh --create \
--bootstrap-server kafka:9092 \
--topic game.gameplay.events \
--partitions 128 \ # Più partizioni: alta throughput
--replication-factor 3 \
--config retention.ms=7776000000 # 90 giorni
--config compression.type=snappy
kafka-topics.sh --create \
--bootstrap-server kafka:9092 \
--topic game.economy.events \
--partitions 32 \
--replication-factor 3 \
--config retention.ms=-1 # Forever (economia critica)
--config cleanup.policy=compact # Compact per mantenerla
5. Flink Stream Processing: Real-Time Analytics
Apache Flink je engine pro zpracování streamů, který si pro něj vybraly EA, Riot Games a další velké společnosti herní telemetrie. Jeho schopnost řídit zpracování času události s vodoznaky a zásadní pro správu pozdních událostí (událostí přicházejících pozdě ze sítě) a jejich stavu distribuovaný umožňuje agregace v průběhu časových oken bez ztráty dat.
// Apache Flink job: Kill/Death ratio in real-time per match (Java/Flink API)
public class KDAStreamProcessor {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
// Checkpoint ogni 10s per fault tolerance
env.enableCheckpointing(10_000);
env.getCheckpointConfig()
.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// Source: Kafka consumer per eventi gameplay
KafkaSource<TelemetryEvent> source = KafkaSource
.<TelemetryEvent>builder()
.setBootstrapServers("kafka:9092")
.setTopics("game.gameplay.events")
.setGroupId("flink-kda-processor")
.setStartingOffsets(OffsetsInitializer.latest())
.setDeserializer(new TelemetryEventDeserializer())
.build();
DataStream<TelemetryEvent> events = env
.fromSource(source, WatermarkStrategy
// Tollerata latenza massima di 30s per eventi ritardati
.<TelemetryEvent>forBoundedOutOfOrderness(Duration.ofSeconds(30))
.withTimestampAssigner((e, ts) -> e.getServerTs()),
"Kafka-Telemetry-Source"
);
// Filtra solo eventi kill e death
DataStream<TelemetryEvent> combatEvents = events
.filter(e -> e.getEventType().equals("gameplay.kill") ||
e.getEventType().equals("gameplay.death"));
// Aggrega KDA per player ogni minuto, keyed by player_id
DataStream<PlayerKDA> kdaStream = combatEvents
.keyBy(TelemetryEvent::getPlayerId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new KDAAggregateFunctionK(), new KDAWindowFunctionK())
.name("KDA-Per-Minute");
// Sink 1: Redis per LiveOps dashboard (latenza < 1s)
kdaStream.addSink(new RedisSink<>(redisConfig, new KDARedisMapper()));
// Sink 2: ClickHouse per storico (latenza accettabile 5-10s)
kdaStream.addSink(ClickHouseSink.builder()
.setTableName("game_analytics.player_kda_minutes")
.build());
env.execute("Game KDA Real-Time Processor");
}
}
// Funzione di aggregazione KDA
public class KDAAggregateFunctionK
implements AggregateFunction<TelemetryEvent, KDAAcc, KDAAcc> {
@Override
public KDAAcc createAccumulator() {
return new KDAAcc(0, 0, 0);
}
@Override
public KDAAcc add(TelemetryEvent event, KDAAcc acc) {
if (event.getEventType().equals("gameplay.kill")) {
return new KDAAcc(acc.kills + 1, acc.deaths, acc.assists);
} else {
return new KDAAcc(acc.kills, acc.deaths + 1, acc.assists);
}
}
@Override
public KDAAcc getResult(KDAAcc acc) { return acc; }
@Override
public KDAAcc merge(KDAAcc a, KDAAcc b) {
return new KDAAcc(a.kills + b.kills, a.deaths + b.deaths,
a.assists + b.assists);
}
}
6. Segmentace hráčů: RFM v reálném čase
Modelka RFM (aktuálnost, frekvence, peněžní), vypůjčené z e-commerce, přizpůsobí dokonale se hodí pro hraní her, aby rozdělilo hráče do skupin, na které se dá reagovat: ti, kteří riskují, že začnou míjet, ti, kteří jsou připraveni na prémiovou nabídku, kdo utrácí nejvyšší částku, s níž je třeba zacházet opatrně.
- Aktuálnost: dny od poslední relace (nízká aktuálnost = riziko odchodu)
- Frekvence: relace za posledních 30 dní (vysoká frekvence = aktivní hráč)
- Měnový: Celková útrata v reálné měně za posledních 90 dní
-- ClickHouse: query RFM per segmentazione giocatori
-- Eseguita ogni ora tramite scheduled materialized view
CREATE MATERIALIZED VIEW game_analytics.player_rfm_segments
ENGINE = ReplacingMergeTree()
ORDER BY (player_id, computed_at)
AS
WITH rfm_base AS (
SELECT
player_id,
-- Recency: giorni dall'ultima sessione
dateDiff('day', max(toDate(server_ts / 1000)), today()) AS recency_days,
-- Frequency: sessioni negli ultimi 30 giorni
countDistinct(
CASE WHEN server_ts > subtractDays(now(), 30) THEN session_id END
) AS frequency_30d,
-- Monetary: spesa totale ultimi 90 giorni (economy events)
sumIf(
toFloat64OrZero(payload['amount_usd']),
event_type = 'economy.purchase' AND
server_ts > subtractDays(now(), 90)
) AS monetary_90d,
now() AS computed_at
FROM game_analytics.events_all
WHERE event_type IN ('player.session_start', 'economy.purchase')
GROUP BY player_id
),
rfm_scored AS (
SELECT
player_id,
recency_days,
frequency_30d,
monetary_90d,
computed_at,
-- Score da 1-5 per ogni dimensione (quintili)
ntile(5) OVER (ORDER BY recency_days DESC) AS r_score, -- Desc: bassa recency = buono
ntile(5) OVER (ORDER BY frequency_30d ASC) AS f_score,
ntile(5) OVER (ORDER BY monetary_90d ASC) AS m_score
FROM rfm_base
)
SELECT
player_id,
recency_days,
frequency_30d,
monetary_90d,
r_score,
f_score,
m_score,
-- Segment label
multiIf(
r_score >= 4 AND f_score >= 4 AND m_score >= 4, 'champions',
r_score >= 4 AND f_score >= 4, 'loyal_players',
r_score >= 4 AND m_score >= 4, 'potential_champions',
r_score <= 2 AND f_score >= 3, 'at_risk',
r_score <= 2 AND f_score <= 2, 'churned_risk',
m_score >= 4, 'big_spenders',
'casual'
) AS segment,
computed_at
FROM rfm_scored;
Segmenty RFM a doporučené akce LiveOps
| Segment | % Typické | Doporučená akce | Kanál |
|---|---|---|---|
| Šampioni | 5% | VIP zacházení, beta přístup, exkluzivní obsah | Ve hře + e-mail |
| Věrní hráči | 15 % | Odměny za věrnost, pobídky k předání bitvy | Ve hře |
| V ohrožení | 20 % | Kampaň na opětovné zapojení, nabídka slevy 30 %. | Push + Email |
| Svržené riziko | 25 % | Win-back: 7 dní prémie zdarma | Email + SMS |
| Velké útraty | 3% | Ochrana velryb, personalizované nabídky | Přímý dosah |
| Neformální | 32 % | Vylepšený výukový program, zjednodušené onboarding | Ve hře |
7. Predikce odchodu s funkcí Feature Engineering na Flinku
Predikce odchodu vyžaduje inženýrství funkcí v reálném čase: extrahování souborů charakteristiky, které model ML používá k predikci pravděpodobnosti opuštění v příštích 7 dnech. Tyto funkce se počítají na posuvných oknech a zapisují se do úložiště funkcí (Redis nebo Feast).
// Flink: Feature engineering per churn prediction
// Calcola feature su finestra scorrevole di 7 giorni
DataStream<ChurnFeatures> churnFeatures = events
.keyBy(TelemetryEvent::getPlayerId)
.window(SlidingEventTimeWindows.of(Time.days(7), Time.hours(1)))
.process(new ChurnFeatureExtractor())
.name("Churn-Feature-Engineering");
public class ChurnFeatureExtractor
extends ProcessWindowFunction<TelemetryEvent, ChurnFeatures, String, TimeWindow> {
@Override
public void process(String playerId, Context ctx,
Iterable<TelemetryEvent> events, Collector<ChurnFeatures> out) {
List<TelemetryEvent> eventList = new ArrayList<>();
events.forEach(eventList::add);
// Feature calcolo
long sessionCount = eventList.stream()
.filter(e -> e.getEventType().equals("player.session_start"))
.count();
double avgSessionDurationMin = eventList.stream()
.filter(e -> e.getEventType().equals("player.session_end"))
.mapToDouble(e -> Double.parseDouble(e.getPayload().getOrDefault("duration_sec", "0")))
.average().orElse(0.0) / 60.0;
long matchesCompleted = eventList.stream()
.filter(e -> e.getEventType().equals("gameplay.match_end"))
.count();
long matchesAbandoned = eventList.stream()
.filter(e -> e.getEventType().equals("gameplay.match_abandon"))
.count();
double abandonRate = matchesCompleted > 0
? (double) matchesAbandoned / (matchesCompleted + matchesAbandoned)
: 0.0;
double totalSpend = eventList.stream()
.filter(e -> e.getEventType().equals("economy.purchase"))
.mapToDouble(e -> Double.parseDouble(e.getPayload().getOrDefault("amount_usd", "0")))
.sum();
long daysSinceLastSession = eventList.stream()
.filter(e -> e.getEventType().equals("player.session_start"))
.mapToLong(TelemetryEvent::getServerTs)
.max()
.map(lastTs -> (System.currentTimeMillis() - lastTs) / 86_400_000L)
.orElse(999L);
out.collect(new ChurnFeatures(
playerId,
sessionCount,
avgSessionDurationMin,
matchesCompleted,
abandonRate,
totalSpend,
daysSinceLastSession,
ctx.window().getEnd()
));
}
}
// Sink su Redis Feature Store con TTL
churnFeatures.addSink(new RedisSink<>(
redisConfig,
(ChurnFeatures f, FlinkJedisPoolConfig config) -> {
try (Jedis jedis = new Jedis(config.getHost(), config.getPort())) {
String key = "churn_features:" + f.getPlayerId();
Map<String, String> fields = new HashMap<>();
fields.put("session_count_7d", String.valueOf(f.getSessionCount()));
fields.put("avg_session_min", String.valueOf(f.getAvgSessionDurationMin()));
fields.put("abandon_rate", String.valueOf(f.getAbandonRate()));
fields.put("total_spend_usd", String.valueOf(f.getTotalSpend()));
fields.put("days_since_last", String.valueOf(f.getDaysSinceLastSession()));
jedis.hset(key, fields);
jedis.expire(key, 86400 * 7); // TTL 7 giorni
}
}
));
8. Kvalita dat: Deduplikace a zpracování pozdních událostí
V distribuovaném systému jsou duplikáty nevyhnutelné: klient může znovu odeslat dávku, pokud ji neobdrží
potvrďte a síťový oddíl může způsobit dvojitý zápis. Deduplikace založená na event_id
musí k tomu dojít co nejdříve.
// Flink: Deduplicazione stateful con event_id
// Usa RocksDB state backend per gestire miliardi di ID
DataStream<TelemetryEvent> deduplicated = rawEvents
.keyBy(TelemetryEvent::getEventId)
.process(new DeduplicationFunction(Duration.ofHours(24)))
.name("Event-Deduplication");
public class DeduplicationFunction
extends KeyedProcessFunction<String, TelemetryEvent, TelemetryEvent> {
private final Duration deduplicationWindow;
private ValueState<Boolean> seenState;
@Override
public void open(Configuration parameters) {
ValueStateDescriptor<Boolean> descriptor =
new ValueStateDescriptor<>("event-seen", Boolean.class);
// TTL: pulisce lo stato dopo 24h per evitare memory leak
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(org.apache.flink.api.common.time.Time.hours(24))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();
descriptor.enableTimeToLive(ttlConfig);
seenState = getRuntimeContext().getState(descriptor);
}
@Override
public void processElement(TelemetryEvent event, Context ctx,
Collector<TelemetryEvent> out) throws Exception {
if (seenState.value() == null) {
// Prima volta che vediamo questo event_id
seenState.update(true);
out.collect(event); // Passa all'avanti
}
// Altrimenti: duplicato, scartato silenziosamente
// (registra metrica per monitoring)
}
}
-- ClickHouse: tabella eventi con ReplacingMergeTree per dedup storage-level
CREATE TABLE game_analytics.events_all (
event_id String,
event_type LowCardinality(String),
player_id String,
session_id String,
server_ts DateTime64(3),
platform LowCardinality(String),
region LowCardinality(String),
match_id Nullable(String),
payload Map(String, String),
ingested_at DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(ingested_at)
PARTITION BY toYYYYMM(server_ts)
ORDER BY (player_id, event_type, server_ts)
SETTINGS index_granularity = 8192;
-- Indice bloom filter su player_id per query veloci per giocatore
ALTER TABLE game_analytics.events_all
ADD INDEX bf_player_id player_id TYPE bloom_filter(0.01) GRANULARITY 4;
Upozornění: Klient časových razítek vs server časových razítek
Nikdy nevěřte client_ts jako zdroj pravdy pro analýzy: klientské hodiny
they can be hours out of phase (especially on mobile), manipulated by cheaters, or simply
špatně. Vždy používejte server_ts pro analytické dotazy. The client_ts a užitečné
jen pro měření latence sítě (server_ts - client_ts) a přestavět
sekvence událostí v rámci jedné relace.
9. Analýza cesty a metriky udržení
Metriky udržení jsou pro hru s živými službami nejdůležitější. Uchování v den 1, den 7 a den 30 (D1/D7/D30) jsou průmyslovým standardem KPI pro hodnocení zdraví hry.
-- ClickHouse: calcolo retention D1/D7/D30 per coorte
-- Una "coorte" e il gruppo di giocatori con lo stesso primo giorno di gioco
SELECT
install_date,
count(DISTINCT player_id) AS cohort_size,
-- D1 Retention: tornati il giorno dopo l'installazione
countDistinctIf(player_id,
dateDiff('day', install_date, last_seen_date) >= 1
) AS retained_d1,
-- D7 Retention
countDistinctIf(player_id,
dateDiff('day', install_date, last_seen_date) >= 7
) AS retained_d7,
-- D30 Retention
countDistinctIf(player_id,
dateDiff('day', install_date, last_seen_date) >= 30
) AS retained_d30,
-- Percentuali
round(100.0 * retained_d1 / cohort_size, 2) AS d1_pct,
round(100.0 * retained_d7 / cohort_size, 2) AS d7_pct,
round(100.0 * retained_d30 / cohort_size, 2) AS d30_pct
FROM (
-- Subquery: per ogni giocatore, data installazione e ultima sessione
SELECT
player_id,
min(toDate(server_ts / 1000)) AS install_date,
max(toDate(server_ts / 1000)) AS last_seen_date
FROM game_analytics.events_all
WHERE event_type = 'player.session_start'
GROUP BY player_id
)
WHERE install_date >= today() - 90 -- Ultimi 90 giorni di coorti
GROUP BY install_date
ORDER BY install_date DESC;
-- Benchmark settore (mobile games 2024):
-- D1: 25-40% (buono), D7: 10-20%, D30: 3-8%
-- Top performers: D1 40%+, D7 20%+, D30 8%+
10. Měřítko: Reálná čísla a úvahy o nákladech
AAA hra o víkendu spuštění může generovat vrcholy 10-50 milionů událostí za minutu. Zde je návod měří potrubí a jaké jsou orientační náklady.
Velikost pro 1 milion souběžných hráčů
| Komponent | Dimenzování | Cena AWS/měsíc (odhad) |
|---|---|---|
| Kafka (MSK) | 12 m5,4xvelcí makléři | ~8 000 $ |
| Flink (EMR) | 20 správce úloh r5.2xlarge | ~12 000 $ |
| ClickHouse (samohoštěný) | 6 uzlů r6a.8xlarge + 50TB SSD | ~15 000 $ |
| Redis (ElastiCache) | 3 r7g.2xvelké uzly clusteru | ~3000 $ |
| Telemetrická brána | Automatické škálování ECS (10–50 úkolů) | ~5 000 $ |
| Celkový | ~43 000 $ měsíčně |
Pro 10 milionů aktivních hráčů měsíčně je cena ~0,004 $/uživatel/měsíc: velká investice odůvodněné schopností optimalizovat ARPU a retenci.
Optimalizace nákladů
- Inteligentní vzorkování: Pro vysokofrekvenční herní události (pozice, animace), odeslat pouze 1 událost z každých 5 namísto všech. Ztratíte jen málo na kvalitě a ušetříte 80 % na propustnosti.
- Víceúrovňové úložiště: ClickHouse s chladnou úrovní na S3. Nedávná data (7 dní) na místním SSD, historická data na S3 s pomalejším přístupem, ale 10x levnější.
- Agregace-před odesláním: Pro jednoduché metriky, jako je fps nebo ping, agregujte na straně klienta (průměr, min, max každých 30 s) místo odesílání každého snímku.
Závěry
Telemetrie průmyslových her není standardní projekt datového inženýrství: vyžaduje hluboké porozumění herním vzorcům, omezením latence a potřebám LiveOps. Kombinace Kafka + Flink + ClickHouse + Redis a stal se de facto standardem v oboru pro svou schopnost zvládat obrovské objemy při zachování subsekundové latence u metrik kritiky.
Segmentace hráčů RFM a predikce odchodu v reálném čase transformují nezpracovaná data na akce konkrétní: cílené kampaně na opětovné zapojení, personalizované nabídky a rozhodnutí LiveOps informováni spíše daty než intuicí. Hry, které investují do kvalitní telemetrie viz v průměru o 15–25 % zlepšení retence D30 ve srovnání s těmi, kteří tak neučiní.
Další kroky v sérii Game Backend
- Předchozí článek: LiveOps: Příznak systému událostí a funkcí
- Další článek: Cloud Gaming: Streamování s WebRTC a Edge Node
- Související statistiky: MLOps for Business – modely umělé inteligence ve výrobě







