Game analytics platform: player events, live dashboards and chat search in one SQL engine
A game analytics platform turns what players do into answers: how many play today, who comes back on day 1 and day 7, where new players drop out, and what they write in chat. SereneDB is its storage and query layer: player events, chat and reports sit in one Postgres-compatible engine, and SQL counts players, follows cohorts, feeds live dashboards and searches the chat.
curl https://install.serenedb.com | shWhat does a game analytics platform do? Five questions about two weeks of a multiplayer game
A game analytics platform collects the events a game sends, from installs and sessions to matches and purchases, together with chat and player reports, and answers questions about them: how many play each day, which release broke the onboarding, who comes back, what players are saying, which accounts to ban. Below are two weeks of a simulated free-to-play game after its season 3 launch. Pick a question to run it; each is one SQL statement.
SELECT ts::DATE AS day, count(*) FILTER (WHERE event = 'install') AS installs, count(DISTINCT player_id) AS dau, count(*) FILTER (WHERE event = 'match_end') AS matches, sum(value) FILTER (WHERE event = 'purchase')::DECIMAL(10, 2) AS revenue FROM events GROUP BY day ORDER BY day;
| day | installs | dau | matches | revenue |
|---|---|---|---|---|
| 2026-10-19 | 554 | 554 | 1483 | 284.75 |
| 2026-10-20 | 260 | 449 | 1362 | 114.88 |
| 2026-10-21 | 206 | 422 | 1311 | 169.87 |
| 2026-10-22 | 177 | 437 | 1365 | 199.85 |
| 2026-10-23 | 158 | 424 | 1352 | 174.87 |
| 2026-10-24 | 144 | 411 | 1278 | 134.90 |
| 2026-10-25 | 134 | 405 | 1314 | 134.91 |
| 2026-10-26 | 126 | 407 | 1288 | 204.86 |
| 2026-10-27 | 119 | 371 | 1080 | 99.92 |
| 2026-10-28 | 113 | 371 | 1192 | 79.89 |
| 2026-10-29 | 108 | 375 | 1223 | 109.88 |
| 2026-10-30 | 104 | 392 | 1328 | 174.84 |
| 2026-10-31 | 100 | 363 | 1184 | 174.85 |
| 2026-11-01 | 97 | 366 | 1326 | 179.82 |
count(DISTINCT player_id) counts each player once a day, however many events they send.SELECT CASE WHEN installed_at < TIMESTAMP '2026-10-26 10:00:00' THEN '1.3.2' WHEN installed_at < TIMESTAMP '2026-10-28 14:00:00' THEN '1.4.0' ELSE '1.4.1' END AS release, CASE WHEN platform = 'android' THEN 'android' ELSE 'ios, pc' END AS platform, count(*) AS installs, round(100.0 * count(*) FILTER (WHERE tutorial) / count(*), 1) AS tutorial_pct, round(100.0 * count(*) FILTER (WHERE played) / count(*), 1) AS match_pct, round(100.0 * count(*) FILTER (WHERE back_d1) / count(*), 1) AS d1_pct FROM ( SELECT p.player_id, p.platform, p.installed_at, bool_or(e.event = 'tutorial_done') AS tutorial, bool_or(e.event = 'match_end') AS played, bool_or(e.ts::DATE = p.installed_at::DATE + 1) AS back_d1 FROM players p JOIN events e USING (player_id) WHERE p.installed_at < TIMESTAMP '2026-11-01 00:00:00' GROUP BY ALL ) GROUP BY ALL ORDER BY release, platform;
| release | platform | installs | tutorial_pct | match_pct | d1_pct |
|---|---|---|---|---|---|
| 1.3.2 | android | 822 | 84.3 | 83.7 | 33.9 |
| 1.3.2 | ios, pc | 828 | 82.6 | 81.4 | 34.2 |
| 1.4.0 | android | 138 | 0 | 0 | 0 |
| 1.4.0 | ios, pc | 142 | 90.1 | 88.7 | 34.5 |
| 1.4.1 | android | 177 | 83.1 | 80.8 | 29.9 |
| 1.4.1 | ios, pc | 196 | 84.2 | 82.7 | 34.2 |
bool_or turns each player's events into one row of yes-or-no steps, and FILTER counts them.SELECT p.installed_at::DATE AS cohort, count(DISTINCT p.player_id) AS installs, round(100.0 * count(DISTINCT e.player_id) FILTER (WHERE e.ts::DATE = p.installed_at::DATE + 1) / count(DISTINCT p.player_id), 1) AS d1_pct, CASE WHEN p.installed_at::DATE + 7 <= DATE '2026-11-01' THEN round(100.0 * count(DISTINCT e.player_id) FILTER (WHERE e.ts::DATE = p.installed_at::DATE + 7) / count(DISTINCT p.player_id), 1) END AS d7_pct FROM players p JOIN events e USING (player_id) WHERE p.installed_at < TIMESTAMP '2026-11-01 00:00:00' GROUP BY cohort ORDER BY cohort;
| cohort | installs | d1_pct | d7_pct |
|---|---|---|---|
| 2026-10-19 | 554 | 34.1 | 13.7 |
| 2026-10-20 | 260 | 32.7 | 11.2 |
| 2026-10-21 | 206 | 32 | 11.2 |
| 2026-10-22 | 177 | 33.9 | 9 |
| 2026-10-23 | 158 | 43.7 | 10.1 |
| 2026-10-24 | 144 | 29.2 | 4.9 |
| 2026-10-25 | 134 | 35.8 | 15.7 |
| 2026-10-26 | 126 | 18.3 | |
| 2026-10-27 | 119 | 17.6 | |
| 2026-10-28 | 113 | 29.2 | |
| 2026-10-29 | 108 | 35.2 | |
| 2026-10-30 | 104 | 25 | |
| 2026-10-31 | 100 | 31 |
SELECT c.ts::DATE AS day, count(*) AS messages, count(*) FILTER (WHERE NOT contains(c.message, 'tutorial')) AS misspelled, count(DISTINCT c.player_id) AS players, count(*) FILTER (WHERE p.platform = 'android') AS android FROM chat_idx c JOIN players p USING (player_id) WHERE c.message @@ ts_levenshtein('tutorial', 1) GROUP BY day ORDER BY day;
| day | messages | misspelled | players | android |
|---|---|---|---|---|
| 2026-10-26 | 16 | 5 | 16 | 16 |
| 2026-10-27 | 25 | 7 | 25 | 25 |
| 2026-10-28 | 9 | 2 | 9 | 9 |
ts_levenshtein('tutorial', 1) matches within one edit, so the search finds them, while an exact search for tutorial returns 36.SELECT c.player_id, count(*) AS messages, min(c.ts)::TIME AS first_at, max(c.ts)::TIME AS last_at, r.reports FROM chat_idx c LEFT JOIN (SELECT player_id, count(*) AS reports FROM reports GROUP BY player_id) r USING (player_id) WHERE c.message @@ ts_levenshtein('free', 1) AND c.message @@ ts_levenshtein('gems', 1) GROUP BY c.player_id, r.reports ORDER BY c.player_id;
| player_id | messages | first_at | last_at | reports |
|---|---|---|---|---|
| 90001 | 20 | 19:02:38 | 20:56:18 | 6 |
| 90002 | 20 | 19:04:27 | 20:59:26 | 10 |
| 90003 | 20 | 19:06:09 | 21:01:20 | 13 |
What a game analytics platform needs from its database, and how SereneDB does it in SQL
The database under a game analytics platform takes in events as game servers send them, counts players by day and by minute, follows install cohorts, turns events into funnels, searches what players write and joins their reports to it, and keeps every raw event. Lists of game analytics tools compare products; these are the jobs, each with the SereneDB feature that does it and its docs.
| Need | How SereneDB does it | Looks like |
|---|---|---|
| Take in player events | Game servers write over the PostgreSQL protocol: INSERT for single events, COPY for batches, from any PostgreSQL client library. Docs → | COPY events FROM STDIN |
| Count players over time | count(DISTINCT player_id) per day or per minute counts each player once, however many events they send. Docs → | count(DISTINCT player_id) |
| Follow install cohorts | D1 and D7 retention are counts of the players with an event a set number of days after their install, and FILTER computes both in one pass. Docs → | FILTER (WHERE e.ts::DATE = p.installed_at::DATE + 1) |
| Build funnels | bool_or turns each player's events into yes-or-no steps: tutorial finished, first match played, back the next day. Docs → | bool_or(e.event = 'tutorial_done') AS tutorial |
| Count players online | A session spans its start and end events, so the players online at each moment are a range join from generate_series to the sessions. Docs → | JOIN sessions s ON g.t BETWEEN s.start_at AND s.end_at |
| Search the chat | An inverted index over the messages: words ranked by BM25, phrases, and matches within an edit distance for misspellings. Docs → | message @@ ts_levenshtein('tutorial', 1) |
| Join reports to what was said | ASOF JOIN pairs each player report with the reported player's last message before it. Docs → | ASOF JOIN chat c ON c.player_id = r.player_id AND r.ts >= c.ts |
| Live dashboards | Grafana's built-in PostgreSQL data source sends each panel's SQL to SereneDB; new events count in the next query once committed. Docs → | floor(extract(epoch from ts)/300)*300 AS "time" |
| Keep every raw event | COPY writes old events to Parquet files partitioned by day, and read_parquet queries them in place, on disk or in S3. Docs → | (FORMAT parquet, PARTITION_BY (day), APPEND) |
| Retention of raw data | A scheduled job inside the server archives and deletes the events past their period. Docs → | CREATE JOB events_history EVERY 1 DAY OFFSET 2 HOURS AS $$ … $$ |
Game analytics pipeline: player events, chat and reports in one engine
Game servers send events over the PostgreSQL protocol, chat and reports land in tables, the chat with an inverted index over its words, players and times, and every question is a SELECT: a count per day, a cohort, a search or an ASOF JOIN. Grafana reads the answers, and a job moves old events to Parquet. Hover a stage, or a lane inside the index.
Game servers send events over the PostgreSQL protocol
A game server or collector batches events and sends them with COPY … FROM STDIN, or with INSERT from any PostgreSQL client library. In COPY's default text format, each line is one event with tab-separated values; \N is NULL. The new rows count in the next query.
Installs, chat messages and reports as rows
The chat service writes each message as a row, and the report button writes the reporter, the reported player and the reason. Players are reported for spam, abuse and cheating; the count below is per reason.
An event is a row
One table for every kind of event: the time, the player, the session, the event's name and one number, a match's score or a purchase's amount. A day of players is count(DISTINCT player_id) over its rows. Events need no index.
One row per install
The install time and the platform of each player. Cohorts, funnels and platform splits join the events to this table.
Chat, as words and terms
Messages go through the chat_text dictionary: lowercased words, version numbers such as 1.4.0 kept whole. The channel is indexed whole, so a count per channel reads the term dictionary.
Reports name a player and a reason
Each report holds its time, the reporter, the reported player and the reason. Joined to the chat, a report comes with what the player wrote just before it.
The chat is indexed, the events are not
One inverted index on the chat: the message through a dictionary, the player and the channel as exact terms, the time as a range. New messages become searchable at the next refresh, every second by default, or at once after VACUUM (REFRESH_TABLE).
Players, matches and revenue per day
One pass over the events: count(DISTINCT player_id) for the players, and FILTER for the installs, the matches and the revenue. This is the lab's first question.
Retention by install day
Each player's events joined to their install: a player counts for D1 with an event the day after installing, and for D7 seven days after. date_diff gives the days since install for a full cohort table.
Search the chat, ranked
Words in a query match any message that has them, and BM25 ranks the messages that fit best first: both words, then the shorter messages. ts_levenshtein matches within an edit distance, for the misspellings in chat. The full-text search page covers dictionaries and phrases in depth.
Each report, with what the player wrote
ASOF JOIN pairs every report with the reported player's last message at or before it, so a moderator reads the message next to the report.
Grafana panels in SQL
Add SereneDB as a PostgreSQL data source on port 7890 and write panels in raw SQL. Grafana replaces its time macros before sending; the statement below is what reached SereneDB.
Old events to Parquet, by a job
COPY … TO with PARTITION_BY writes one folder of Parquet files per day, and read_parquet queries them in place. With an S3 secret the same COPY writes to a bucket. A job does it daily and deletes what it moved.
What player data needs from one engine
events · players · chat · reportsNo special table type: an event is a row, and a day of players is a count over its rows.
SELECT count(*) FROM eventsChat matches within an edit distance, so tutorail and gemz are found, and BM25 puts the best matches first.
ts_levenshtein('tutorial', 1)ASOF JOIN pairs each report with the last thing the reported player wrote before it.
ASOF JOIN chat c ON c.player_id = r.player_idSearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.
otel_logs_1b · 26.09.1Tutorial: player retention, funnels and chat search in SQL, in five steps
From an empty server to a list of spam accounts: start SereneDB, create the tables, load two weeks of a game, find the release that broke the tutorial in the funnel and in retention, then search the chat and keep the history.
docker run -d --name serenedb -p 7890:7890 \ -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2 psql -h localhost -p 7890 -U postgres
CREATE TABLE players ( player_id INTEGER, installed_at TIMESTAMP, platform VARCHAR -- android, ios or pc ); CREATE TABLE events ( ts TIMESTAMP, player_id INTEGER, session_id BIGINT, event VARCHAR, -- install, session_start, tutorial_done, -- match_end, purchase or session_end value DOUBLE -- a match's score, a purchase's amount ); CREATE TABLE chat ( id INTEGER, ts TIMESTAMP, player_id INTEGER, channel VARCHAR, -- global or match message VARCHAR ); CREATE TABLE reports ( ts TIMESTAMP, reporter_id INTEGER, player_id INTEGER, -- the reported player reason VARCHAR -- spam, abuse or cheating ); CREATE TEXT SEARCH DICTIONARY chat_text AS split_text(case := 'lower') WITH (frequency, position, norm); CREATE INDEX chat_idx ON chat USING inverted (id, ts, player_id, channel, message chat_text);
-- Two weeks of a free-to-play multiplayer game, generated in SQL. Season 3 -- opened on Monday 2026-10-19, and 2,400 players installed the game by -- Sunday 2026-11-01. rnd(a, b) is a number in [0, 1) taken from an md5, -- so every run gives the same rows. CREATE MACRO rnd(a, b) AS (md5_number_lower(a || ':' || b) % 1000000) / 1000000.0; INSERT INTO players SELECT row_number() OVER (ORDER BY installed_at)::INTEGER, installed_at, platform FROM ( SELECT DATE '2026-10-19' + floor(14 * power((p - 0.5) / 2400, 1.8))::INTEGER + to_seconds(28800 + floor(54000 * rnd(p, 'at'))::BIGINT) AS installed_at, CASE WHEN rnd(p, 'os') < 0.5 THEN 'android' WHEN rnd(p, 'os') < 0.8 THEN 'ios' ELSE 'pc' END AS platform FROM range(1, 2401) AS r(p) ); -- Sessions, and what happened in them. Patch 1.4.0 (2026-10-26 10:00) broke -- tutorial step 3 on Android until hotfix 1.4.1 (2026-10-28 14:00), so the -- Android players who installed in between could not finish the tutorial. -- Players who do not finish it never come back. From 2026-10-29, player -- 1906 cheats: every match scores three times the usual. INSERT INTO events WITH p AS ( SELECT *, rnd(player_id, 'tutorial') < 0.82 AND NOT (platform = 'android' AND installed_at >= TIMESTAMP '2026-10-26 10:00:00' AND installed_at < TIMESTAMP '2026-10-28 14:00:00') AS onboarded FROM players ), days AS ( SELECT p.*, k, installed_at::DATE + k::INTEGER AS day FROM p, range(14) AS r(k) WHERE installed_at::DATE + k::INTEGER <= DATE '2026-11-01' AND (k = 0 OR (onboarded AND rnd(player_id, k) < 0.45 * power(k, -0.5) * (0.4 + rnd(player_id, 'keen')))) ), sessions AS ( SELECT * FROM ( SELECT player_id, onboarded, day, k, j, player_id * 100 + k * 3 + j AS session_id, CASE WHEN k = 0 THEN installed_at + to_hours(3 * j) ELSE day + to_seconds(28800 + 18000 * j + floor(14400 * rnd(player_id, 'at' || k || j)) ::BIGINT) END AS start_at, CASE WHEN onboarded THEN 8 + floor(36 * rnd(player_id, 'len' || k || j)) ELSE 4 + floor(6 * rnd(player_id, 'len' || k || j)) END::INTEGER AS minutes FROM days, range(3) AS s(j) WHERE j < 1 + floor(2.6 * rnd(player_id, 'n' || k)) AND (onboarded OR j = 0) ) WHERE start_at < day + INTERVAL '23 hours' ) SELECT installed_at, player_id, player_id * 100, 'install', NULL FROM players UNION ALL SELECT start_at, player_id, session_id, 'session_start', NULL FROM sessions UNION ALL SELECT start_at + INTERVAL '7 minutes', player_id, session_id, 'tutorial_done', NULL FROM sessions WHERE onboarded AND k = 0 AND j = 0 UNION ALL SELECT start_at + to_minutes(12 + 9 * m), player_id, session_id, 'match_end', (600 + floor(1800 * rnd(session_id, 'score' || m))) * CASE WHEN player_id = 1906 AND start_at >= DATE '2026-10-29' THEN 3 ELSE 1 END FROM sessions, range(4) AS g(m) WHERE onboarded AND 12 + 9 * m < minutes UNION ALL SELECT start_at + INTERVAL '2 minutes', player_id, session_id, 'purchase', [4.99, 9.99, 19.99][1 + floor(3 * rnd(session_id, 'sku'))::INTEGER] FROM sessions WHERE onboarded AND rnd(player_id, 'payer') < 0.07 AND rnd(session_id, 'buy') < 0.25 UNION ALL SELECT start_at + to_minutes(minutes), player_id, session_id, 'session_end', NULL FROM sessions; -- Chat: talk after matches; the Android players stuck in the tutorial; -- players 386 and 1310, who insult their teams; and three spam accounts -- that post a "free gems" link on 2026-10-30 from 19:00 to 21:00. INSERT INTO chat SELECT row_number() OVER (ORDER BY ts, player_id)::INTEGER, ts, player_id, channel, message FROM ( SELECT ts - INTERVAL '20 seconds' AS ts, player_id, 'match' AS channel, CASE WHEN player_id IN (386, 1310) THEN ['uninstall noob', 'worst team ever, trash', 'report this noob, he keeps feeding', 'you are all trash lol' ][1 + floor(4 * rnd(session_id, 'abuse' || ts))::INTEGER] ELSE ['gg', 'gg wp', 'nice shot', 'anyone up for ranked?', 'lag on eu servers tonight', 'need one more for a squad', 'that last round was close', 'who wants to duo?', 'how do i unlock the new map?', 'season 3 skins look great', 'rematch?', 'thanks for the carry', 'my team left early again', 'ranked queue is fast today', 'lol that was wild', 'good luck everyone' ][1 + floor(16 * rnd(session_id, 'line' || ts))::INTEGER] END AS message FROM events WHERE event = 'match_end' AND rnd(session_id, 'talk' || ts) < CASE WHEN player_id IN (386, 1310) THEN 0.35 ELSE 0.08 END UNION ALL SELECT installed_at + INTERVAL '3 minutes', player_id, 'global', ['stuck on tutorial step 3, the button does nothing', 'tutorail step 3 wont load after the update', 'is the tutorial bugged on android? cant finish it', 'tutorial freezes at step 3 every time', 'cant get past the tutoral, any fix?', 'step 3 of the tutorial is broken since the patch', 'reinstalled twice, still stuck in the tutorial', 'tutorial broke after 1.4.0 on my phone' ][1 + floor(8 * rnd(player_id, 'stuck'))::INTEGER] FROM players WHERE platform = 'android' AND installed_at >= TIMESTAMP '2026-10-26 10:00:00' AND installed_at < TIMESTAMP '2026-10-28 14:00:00' AND rnd(player_id, 'complain') < 0.3 UNION ALL SELECT TIMESTAMP '2026-10-30 19:00:00' + to_seconds(360 * n + 120 * b + floor(100 * rnd(b, n))::BIGINT), 90000 + b, 'global', ['FREE GEMS at gemz-free.example, no survey', 'free gemz here >> gemz-free.example', 'get fre gems now: gemz-free.example', '1000 free gems, claim fast at gemz-free.example' ][1 + floor(4 * rnd(b, 'msg' || n))::INTEGER] FROM range(1, 4) AS bots(b), range(20) AS t(n) ); -- Players report the spam, the insults and the cheater. INSERT INTO reports SELECT ts + to_seconds(20 + floor(100 * rnd(id, 'delay'))::BIGINT), 1 + floor(2400 * rnd(id, 'reporter'))::INTEGER, player_id, CASE WHEN player_id > 90000 THEN 'spam' ELSE 'abuse' END FROM chat WHERE player_id IN (386, 1310, 90001, 90002, 90003) AND rnd(id, 'report') < 0.4 UNION ALL SELECT ts + INTERVAL '1 minute', 1 + floor(2400 * rnd(session_id, 'by' || ts))::INTEGER, player_id, 'cheating' FROM events WHERE event = 'match_end' AND player_id = 1906 AND ts >= DATE '2026-10-29' AND rnd(session_id, 'report' || ts) < 0.4; VACUUM (REFRESH_TABLE) chat; SELECT (SELECT count(*) FROM players) AS players, (SELECT count(*) FROM events) AS events, (SELECT count(*) FROM chat) AS chat, (SELECT count(*) FROM reports) AS reports;
SELECT CASE WHEN installed_at < TIMESTAMP '2026-10-26 10:00:00' THEN '1.3.2' WHEN installed_at < TIMESTAMP '2026-10-28 14:00:00' THEN '1.4.0' ELSE '1.4.1' END AS release, CASE WHEN platform = 'android' THEN 'android' ELSE 'ios, pc' END AS platform, count(*) AS installs, round(100.0 * count(*) FILTER (WHERE tutorial) / count(*), 1) AS tutorial_pct, round(100.0 * count(*) FILTER (WHERE played) / count(*), 1) AS match_pct, round(100.0 * count(*) FILTER (WHERE back_d1) / count(*), 1) AS d1_pct FROM ( SELECT p.player_id, p.platform, p.installed_at, bool_or(e.event = 'tutorial_done') AS tutorial, bool_or(e.event = 'match_end') AS played, bool_or(e.ts::DATE = p.installed_at::DATE + 1) AS back_d1 FROM players p JOIN events e USING (player_id) WHERE p.installed_at < TIMESTAMP '2026-11-01 00:00:00' GROUP BY ALL ) GROUP BY ALL ORDER BY release, platform; SELECT p.installed_at::DATE AS cohort, count(DISTINCT p.player_id) AS installs, round(100.0 * count(DISTINCT e.player_id) FILTER (WHERE e.ts::DATE = p.installed_at::DATE + 1) / count(DISTINCT p.player_id), 1) AS d1_pct, CASE WHEN p.installed_at::DATE + 7 <= DATE '2026-11-01' THEN round(100.0 * count(DISTINCT e.player_id) FILTER (WHERE e.ts::DATE = p.installed_at::DATE + 7) / count(DISTINCT p.player_id), 1) END AS d7_pct FROM players p JOIN events e USING (player_id) WHERE p.installed_at < TIMESTAMP '2026-11-01 00:00:00' GROUP BY cohort ORDER BY cohort;
SELECT c.ts::DATE AS day, count(*) AS messages, count(*) FILTER (WHERE NOT contains(c.message, 'tutorial')) AS misspelled, count(DISTINCT c.player_id) AS players, count(*) FILTER (WHERE p.platform = 'android') AS android FROM chat_idx c JOIN players p USING (player_id) WHERE c.message @@ ts_levenshtein('tutorial', 1) GROUP BY day ORDER BY day; SELECT c.player_id, count(*) AS messages, min(c.ts)::TIME AS first_at, max(c.ts)::TIME AS last_at, r.reports FROM chat_idx c LEFT JOIN (SELECT player_id, count(*) AS reports FROM reports GROUP BY player_id) r USING (player_id) WHERE c.message @@ ts_levenshtein('free', 1) AND c.message @@ ts_levenshtein('gems', 1) GROUP BY c.player_id, r.reports ORDER BY c.player_id; CREATE JOB events_history EVERY 1 DAY OFFSET 2 HOURS AS $$ COPY (SELECT *, ts::DATE AS day FROM events WHERE ts < current_date - INTERVAL '90 days') TO '/tmp/events_history' (FORMAT parquet, PARTITION_BY (day), APPEND); DELETE FROM events WHERE ts < current_date - INTERVAL '90 days'; $$;
What game studios build on SereneDB
Live ops: how many players are online
A session runs from its session_start event to its session_end, so the players online at a moment are the sessions that span it: a range join from generate_series to the sessions. On the last Sunday evening the game peaked at 16 players online, at 18:30. In a Grafana panel, $__timeFrom()::TIMESTAMP and $__timeTo()::TIMESTAMP take the place of the two timestamps.
WITH sessions AS ( SELECT session_id, player_id, min(ts) AS start_at, max(ts) AS end_at FROM events WHERE event IN ('session_start', 'session_end') GROUP BY session_id, player_id ) SELECT g.t::TIME AS at, count(DISTINCT s.player_id) AS online FROM generate_series(TIMESTAMP '2026-11-01 18:00:00', TIMESTAMP '2026-11-01 22:00:00', INTERVAL '30 minutes') AS g(t) LEFT JOIN sessions s ON g.t BETWEEN s.start_at AND s.end_at GROUP BY g.t ORDER BY g.t;
| at | online |
|---|---|
| 18:00:00 | 4 |
| 18:30:00 | 16 |
| 19:00:00 | 11 |
| 19:30:00 | 11 |
| 20:00:00 | 15 |
| 20:30:00 | 13 |
| 21:00:00 | 8 |
| 21:30:00 | 8 |
| 22:00:00 | 7 |
Monetization: payers and revenue per install by day 7
A purchase is an event with its amount in value, so payer conversion and revenue per install are aggregates over each install cohort's first seven days. The launch-day cohort converted 6.5 % of its 554 players and brought 1.20 per install; the cohorts after it ran from 2.7 to 7.5 %.
SELECT p.installed_at::DATE AS cohort, count(DISTINCT p.player_id) AS installs, count(DISTINCT e.player_id) AS payers_d7, round(100.0 * count(DISTINCT e.player_id) / count(DISTINCT p.player_id), 1) AS payer_pct, (coalesce(sum(e.value), 0) / count(DISTINCT p.player_id))::DECIMAL(10, 2) AS revenue_per_install FROM players p LEFT JOIN events e ON e.player_id = p.player_id AND e.event = 'purchase' AND e.ts < p.installed_at::DATE + 7 WHERE p.installed_at < DATE '2026-10-26' GROUP BY cohort ORDER BY cohort;
| cohort | installs | payers_d7 | payer_pct | revenue_per_install |
|---|---|---|---|---|
| 2026-10-19 | 554 | 36 | 6.5 | 1.20 |
| 2026-10-20 | 260 | 7 | 2.7 | 0.60 |
| 2026-10-21 | 206 | 8 | 3.9 | 0.65 |
| 2026-10-22 | 177 | 6 | 3.4 | 0.65 |
| 2026-10-23 | 158 | 8 | 5.1 | 1.42 |
| 2026-10-24 | 144 | 4 | 2.8 | 0.42 |
| 2026-10-25 | 134 | 10 | 7.5 | 1.60 |
Anti-cheat: scores far above everyone else's
Each player's median score over the last four days, against the median of all players: player 1906 scores 2.6 times the typical player and was reported for cheating 8 times, while the next players stay at 1.5 times. The window, the minimum number of matches and the factor are SQL, so they are yours to change.
Log analysis →WITH per_player AS ( SELECT player_id, count(*) AS matches, median(value) AS median_score FROM events WHERE event = 'match_end' AND ts >= DATE '2026-10-29' GROUP BY player_id HAVING count(*) >= 5 ) SELECT player_id, matches, median_score, round(median_score / (SELECT median(median_score) FROM per_player), 1) AS x_typical, (SELECT count(*) FROM reports r WHERE r.player_id = per_player.player_id AND r.reason = 'cheating') AS reports FROM per_player ORDER BY median_score DESC LIMIT 3;
| player_id | matches | median_score | x_typical | reports |
|---|---|---|---|---|
| 1906 | 17 | 3894 | 2.6 | 8 |
| 1485 | 6 | 2332 | 1.5 | 0 |
| 2246 | 7 | 2255 | 1.5 | 0 |
A game analytics platform collects the events a game sends about its players, stores them and turns them into answers: dashboards of daily players and revenue, retention and funnel reports, and alerts. Its parts are the event tracking in the game, storage and query, and the dashboards on top. SereneDB is the storage and query part, in SQL.
The game client and servers send events to a collector, which batches them into a database; dashboards, reports and scheduled jobs read from it. With SereneDB the game servers write over the PostgreSQL protocol, events, players, chat and reports are four tables, and the architecture above follows them from ingest to a Grafana panel.
Players, installs, matches and revenue per day; D1 and D7 retention by install day; the onboarding funnel per platform; and players online for live ops. Each panel is one SQL query: the lab's first question is the daily panel, and the live ops example counts players online.
Take in player events as game servers send them, count players over days and minutes, follow install cohorts, search the chat and keep the raw events for later analysis. SereneDB does all of it in one SQL engine; the checklist above maps each need to its feature and docs.
Join each player's install to their events and count the players with an event on the calendar day after the install (D1) or seven days after it (D7), divided by the day's installs. With FILTER both are one query, as in step 4: D1 was 34.1 % for the launch-day cohort and 17.6 % for the installs of 2026-10-27.
Reduce each player's events to one row of yes-or-no steps with bool_or, such as tutorial finished, first match played and back the next day, then count each step with FILTER, grouped by release and platform. The lab's funnel question shows 0 % of Android installs finishing the tutorial under patch 1.4.0.
Turn each session's start and end events into a time range, then join a generate_series of moments to the ranges that contain them and count distinct players, as in the live ops example. The same query with Grafana's time macros becomes a dashboard panel.
Index the chat with an inverted index and search in SQL: words ranked by BM25, phrases, and matches within an edit distance, which catch misspellings such as gemz for gems. Each hit is a row, so it can be counted per player, joined to the reports or grouped by hour.
Game telemetry is the stream of events a game sends about play: installs, sessions, matches, purchases. Store it as one row per event with the time, the player, the session, the event's name and a value, as in step 2; queries group the rows by day, player or session.
As long as you choose: events stay in their table until deleted. A scheduled job can copy the events past a period to Parquet files partitioned by day and delete them, and read_parquet still queries those files, on disk or in S3.
Yes. Grafana's built-in PostgreSQL data source connects to SereneDB on port 7890, and each panel is a SQL query. Grafana expands macros such as $__timeFilter into plain SQL before sending it.
Yes, under the Apache-2.0 license, with its source on GitHub. Its Docker image runs the walkthrough above with one command.
Get started with SereneDB
Start SereneDB, send your player events and chat, and query them in SQL from Grafana or any PostgreSQL client.