Game Telemetry Pipeline: Speleranalyse bij Scala
Elke dag genereert een modern multiplayerspel miljarden gebeurtenissen: een speler beweegt, voert een aanval uit, een item kopen, een spel verlaten. Fortnite overtreft 350 miljoen geregistreerde spelers en genereert tientallen van terabytes aan data per dag. League of Legends verzamelt meer dan 100 variabelen per voltooide wedstrijd. Oproep van Duty registreert elke afgevuurde kogel, elke dode, elk frame waarin de latentie de kritische drempel overschrijdt.
Deze enorme hoeveelheid gegevens is geen achtergrondgeluid: het is het zenuwstelsel van het spel. En de telemetrie om te onthullen waarom spelers het na het derde niveau opgeven, welk wapen te sterk is in de meta, welke serverregio last heeft van vertraging tijdens piekuren, en hoe lang het duurt voordat een deel van de gebruikers stopt met betalen. Zonder een robuuste telemetrieleiding rijd je geblinddoekt met 200 km/u.
In dit artikel bouwen we er een industrieel telemetriespel voor pijpleidingen end-to-end: uit de collectie van gebeurtenissen aan de client- en serverzijde, opname met Apache Kafka, realtime verwerking met Apache Flink, tot het datawarehouse voor historische analyse en het dashboard voor LiveOps-beslissingen. Wij zullen het ook zien hoe de te implementeren segmentatie van spelers en de voorspelling van het verloop direct op de pijpleiding.
Wat je gaat leren
- Taxonomie van gamegebeurtenissen: spelergebeurtenissen, gameplay-gebeurtenissen, economische gebeurtenissen, systeemgebeurtenissen
- Pijplijnarchitectuur: client-SDK, gebeurtenisbus, streamverwerking, datawarehouse
- Implementatie met Kafka, Flink en ClickHouse voor realtime analyses
- Berichtschema met Avro en Schema Registry voor validatie
- Realtime RFM-spelersegmentatie (recentheid, frequentie, monetair).
- Churn-voorspelling met feature-engineering op de Flink-pijplijn
- Trechteranalyse en retentiestatistieken voor LiveOps-beslissingen
- Gegevenskwaliteit, deduplicatie en afhandeling van late gebeurtenissen
1. Taxonomie van game-evenementen
Voordat u uw pijplijn opbouwt, moet u weten wat u verzamelt. De game-evenementen zijn in vier verdeeld hoofdcategorieën, elk met verschillende frequentie, prioriteit en verzamelmethoden.
| Categorie | Voorbeelden | Frequentie | Prioriteit | Behoud |
|---|---|---|---|---|
| Speler-evenementen | inloggen, uitloggen, session_start, level_up, prestatie | Laag (1-10/u) | Hoog | Voor altijd |
| Gameplay-evenementen | doden, dood, match_start, vaardigheid_gebruikt, zone_enter | Hoog (100-1000/min) | Gemiddeld | 90 dagen |
| Economie evenementen | aankoop, item_grant, valuta_verdienen, winkel_open | Laag (0-5/u) | Kritiek | Voor altijd |
| Systeemgebeurtenissen | fps_drop, packet_loss, opnieuw verbinden, crash_report | Gemiddeld (10-100/min) | Hoog | 30 dagen |
| Sociale evenementen | vriend_toevoegen, party_join, chat_sent, report_player | Laag (0-20/u) | Gemiddeld | 180 dagen |
Elke gebeurtenis moet een gemeenschappelijke structuur (envelop) hebben met standaardvelden: event_id,
event_type, player_id, session_id, server_timestamp,
client_timestampen een typespecifieke payload. Het rigide en fundamentele schema voor de
stroomafwaartse gegevenskwaliteit.
// 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. Pipeline-architectuur: van client tot dashboard
De architectuur van een industriële game-telemetriepijplijn volgt dit patroon Lambda-architectuur of, in de modernere versie, de Kappa-architectuur waar een verwerkingslaag voor een enkele stroom is dient zowel realtime als historische analyse.
Pijpleidingcomponenten
| Lagen | Onderdeel | Technologie | Rol |
|---|---|---|---|
| Verzameling | Client-SDK | TypeScript/C++/Unity | Bufferen, batchen, opnieuw proberen |
| Verzameling | Telemetrie-gateway | Ga/gezant | Authn, snelheidsbeperking, fan-out |
| Inslikken | Evenementenbus | Apache Kafka | Duurzaamheid, herhaling, bestellen |
| Verwerking | Stream-engine | Apache Flink | Real-time analyses, verrijking |
| Portie | OLAP-database | KlikHuis | Snelle zoekopdrachten over miljarden rijen |
| Portie | Hete winkel | Opnieuw | Realtime statistieken voor LiveOps |
| Visualisatie | Dashboards | Grafana/Metabase | LiveOps-dashboard en analyses |
3. Client-SDK: bufferen en batchen
Het afzonderlijk verzenden van elke gebeurtenis naar de server is een klassieke fout die enorme netwerkoverhead veroorzaakt. Een goede client-SDK implementeert lokale buffering e batch verzenden: de evenementen ze worden verzameld in een buffer in het geheugen en periodiek verzonden of wanneer de buffer een bepaalde waarde bereikt maat. In het geval van een netwerkstoring gebruikt de SDK een schijfbuffer om geen evenementen te missen.
// 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-onderwerpen en schemaregister
Het hart van de pijplijn is Apache Kafka. Evenementen worden per categorie over afzonderlijke onderwerpen gepubliceerd, waardoor consumenten zich alleen kunnen registreren voor de soorten evenementen die hen interesseren. De Schemaregister Confluent zorgt ervoor dat elk bericht het gedefinieerde Avro-schema respecteert en verkeerd ingedeelde berichten blokkeert voordat ze het datawarehouse besmetten.
# 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-verwerking: realtime analyse
Apache Flink is de streamverwerkingsengine waarvoor EA, Riot Games en andere majors hebben gekozen gaming-telemetrie. Zijn vermogen om te managen tijdverwerking van gebeurtenissen met watermerken en van fundamenteel belang voor het beheer van late gebeurtenissen (gebeurtenissen die te laat binnenkomen via het netwerk) en de status ervan gedistribueerd maakt aggregaties over tijdsperioden mogelijk zonder gegevensverlies.
// 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. Spelersegmentatie: RFM in realtime
Het model RFM (recentheid, frequentie, monetair), geleend van e-commerce, past zich aan perfect geschikt voor gaming om spelers te segmenteren in bruikbare clusters: degenen die het risico lopen op churn, degenen die dat wel zijn klaar voor een premium aanbieding, die een topspender is die met zorg moet worden behandeld.
- Recentheid: dagen sinds de laatste sessie (lage recentheid = churnrisico)
- Frequentie: sessies in de afgelopen 30 dagen (hoge frequentie = actieve speler)
- Monetair: Totale uitgaven in echte valuta gedurende de afgelopen 90 dagen
-- 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;
RFM-segmenten en aanbevolen LiveOps-acties
| Segment | % Typisch | Aanbevolen actie | Kanaal |
|---|---|---|---|
| Kampioenen | 5% | VIP-behandeling, bètatoegang, exclusieve inhoud | In-game + e-mail |
| Trouwe spelers | 15% | Loyaliteitsbeloningen, incentives voor gevechtspassen | In het spel |
| In gevaar | 20% | Hernieuwde betrokkenheidscampagne, aanbieding van 30% korting | Push + e-mail |
| Gekarnd risico | 25% | Win-back: 7 dagen gratis premium | E-mail + sms |
| Grote spenders | 3% | Walvisbescherming, gepersonaliseerde aanbiedingen | Direct bereik |
| Casual | 32% | Verbeterde tutorial, gestroomlijnde onboarding | In het spel |
7. Churn-voorspelling met Feature Engineering op Flink
Churn-voorspelling vereist real-time feature-engineering: het extraheren van le kenmerken die het ML-model gebruikt om de waarschijnlijkheid van stopzetting in de komende zeven dagen te voorspellen. Deze features worden berekend op schuifvensters en geschreven naar een feature store (Redis of 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. Gegevenskwaliteit: deduplicatie en afhandeling van late gebeurtenissen
In een gedistribueerd systeem zijn duplicaten onvermijdelijk: een klant kan een batch opnieuw verzenden als hij deze niet ontvangt
bevestigen, en een netwerkpartitie kan dubbele schrijfbewerkingen veroorzaken. Ontdubbeling op basis van event_id
het moet zo vroeg mogelijk in de pijplijn gebeuren.
// 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;
Waarschuwing: tijdstempelclient versus tijdstempelserver
Vertrouw nooit de client_ts als bron van waarheid voor analyses: klantklokken
ze kunnen uren uit fase zijn (vooral op mobiel), gemanipuleerd door valsspelers of gewoonweg
verkeerd. Altijd gebruiken server_ts voor analytische vragen. De client_ts en nuttig
alleen om de netwerklatentie te meten (server_ts - client_ts) en opnieuw op te bouwen
reeks gebeurtenissen binnen één sessie.
9. Trechteranalyse en retentiestatistieken
Retentiestatistieken zijn het belangrijkst voor een live-servicegame. Retentie op dag 1, dag 7 en dag 30 (D1/D7/D30) zijn de industriestandaard KPI's voor het evalueren van de gamegezondheid.
-- 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. Schaalvergroting: reële cijfers en kostenoverwegingen
Een AAA-game kan tijdens het lanceringsweekend pieken genereren van 10-50 miljoen evenementen per minuut. Hier is hoe is de pijpleiding groot en wat zijn de indicatieve kosten.
Maatvoering voor 1 miljoen gelijktijdige spelers
| Onderdeel | Maatvoering | AWS-kosten/maand (geschat) |
|---|---|---|
| Kafka (MSK) | 12 m5.4xgrote makelaars | ~ $ 8.000 |
| Flink (EMR) | 20 taakbeheer r5.2xlarge | ~ $ 12.000 |
| ClickHouse (zelf gehost) | 6 knooppunten r6a.8xlarge + 50TB SSD | ~ $ 15.000 |
| Redis (ElastiCache) | 3 r7g.2xlarge clusterknooppunten | ~ $ 3.000 |
| Telemetrie-gateway | ECS automatisch schalen (10-50 taken) | ~ $ 5.000 |
| Totaal | ~$43.000/maand |
Voor 10 miljoen maandelijks actieve spelers bedragen de kosten ~$0,004/gebruiker/maand: een grote investering gerechtvaardigd door het vermogen om de ARPU en retentie te optimaliseren.
Kostenoptimalisaties
- Intelligente bemonstering: Voor hoogfrequente gameplay-evenementen (positie, animatie), stuur slechts 1 van de 5 gebeurtenissen in plaats van allemaal. U verliest weinig aan kwaliteit en bespaart 80% aan doorvoer.
- Gelaagde opslag: ClickHouse met koude laag op S3. Recente gegevens (7 dagen) op lokale SSD, historische gegevens over S3 met langzamere toegang maar 10x goedkoper.
- Aggregatie vóór verzending: Voor eenvoudige statistieken zoals fps of ping, aggregatie aan de clientzijde (gemiddeld, min, max elke 30s) in plaats van elk frame te verzenden.
Conclusies
Een industriële game-telemetriepijplijn is geen standaard data-engineeringproject: het vereist een diep begrip van spelpatronen, latentiebeperkingen en LiveOps-behoeften. De combinatie Kafka + Flink + ClickHouse + Redis en is de de facto standaard geworden in de branche vanwege zijn vermogen om enorme volumes te verwerken en tegelijkertijd een latentie van minder dan een seconde op statistieken te behouden kritiek.
RFM-spelersegmentatie en realtime churn-voorspelling transformeren ruwe gegevens in acties concreet: gerichte re-engagementcampagnes, gepersonaliseerde aanbiedingen en LiveOps-beslissingen gebaseerd op data in plaats van op intuïtie. Games die investeren in hoogwaardige telemetrie zien gemiddeld een verbetering van 15-25% in D30-retentie vergeleken met degenen die dat niet doen.
Volgende stappen in de Game Backend-serie
- Vorig artikel: LiveOps: evenementensysteem en featurevlag
- Volgend artikel: Cloudgaming: streaming met WebRTC en Edge Node
- Gerelateerde inzichten: MLOps for Business - AI-modellen in productie







