SereneDB

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 | sh
events · 2,400 players · 42,064 rowspatch 1.4.0query
An animation in three scenes over two simulated weeks of a free-to-play game, 2026-10-19 to 2026-11-01. First, the events of 2,400 players are counted per day with count(DISTINCT player_id): 554 players on launch day, and 371 on 2026-10-27, after patch 1.4.0 broke the tutorial on Android. Second, retention by install cohort, one row per install day and one cell per day since install: D1 retention is 35.8 % for the installs of 2026-10-25 and 17.6 % for those of 2026-10-27. Third, the chat of 2026-10-30 is searched for free and gems, each within one edit, which finds the posts of three spam accounts, including the spellings gemz and fre; the most reported account posted 20 times and was reported 13 times.
What is a game analytics platform

What 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.

season lab · a free-to-play multiplayer game
2026-10-19 – 2026-11-012,400 players · 42,064 events · 1,581 chat messages · 46 reports
10-1910-2110-2310-2510-2710-2910-31players / daytutorial done %chatreports1.4.01.4.1androidios, pctutorialfree gemsinsults
Players
sql
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;
result · 14 rows
dayinstallsdaumatchesrevenue
2026-10-195545541483284.75
2026-10-202604491362114.88
2026-10-212064221311169.87
2026-10-221774371365199.85
2026-10-231584241352174.87
2026-10-241444111278134.90
2026-10-251344051314134.91
2026-10-261264071288204.86
2026-10-27119371108099.92
2026-10-28113371119279.89
2026-10-291083751223109.88
2026-10-301043921328174.84
2026-10-311003631184174.85
2026-11-01973661326179.82
554 players installed on launch day, and the daily count settled near 410 a week later. It falls to 371 on 2026-10-27 and 2026-10-28, after patch 1.4.0, with fewer matches and less revenue. count(DISTINCT player_id) counts each player once a day, however many events they send.
Funnel
sql
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;
result · 6 rows
releaseplatforminstallstutorial_pctmatch_pctd1_pct
1.3.2android82284.383.733.9
1.3.2ios, pc82882.681.434.2
1.4.0android138000
1.4.0ios, pc14290.188.734.5
1.4.1android17783.180.829.9
1.4.1ios, pc19684.282.734.2
Under patch 1.4.0, none of the 138 Android players who installed finished the tutorial or played a match, against 90.1 % of iOS and PC players in the same hours. After hotfix 1.4.1, Android is back at 83.1 %. bool_or turns each player's events into one row of yes-or-no steps, and FILTER counts them.
Retention
sql
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;
result · 13 rows
cohortinstallsd1_pctd7_pct
2026-10-1955434.113.7
2026-10-2026032.711.2
2026-10-212063211.2
2026-10-2217733.99
2026-10-2315843.710.1
2026-10-2414429.24.9
2026-10-2513435.815.7
2026-10-2612618.3
2026-10-2711917.6
2026-10-2811329.2
2026-10-2910835.2
2026-10-3010425
2026-10-3110031
D1 retention is the share of a day's installs who play again the next calendar day. It falls to 18.3 % and 17.6 % for the installs of 2026-10-26 and 2026-10-27, the two days patch 1.4.0 kept new Android players out of the game; the other cohorts run from 25.0 to 43.7 %. D7 shows only for cohorts with seven days of data.
Chat search
sql
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;
result · 3 rows
daymessagesmisspelledplayersandroid
2026-10-261651616
2026-10-272572525
2026-10-289299
50 messages mention the tutorial, all from Android players and all under patch 1.4.0. 14 spell it "tutorail" or "tutoral": ts_levenshtein('tutorial', 1) matches within one edit, so the search finds them, while an exact search for tutorial returns 36.
Moderation
sql
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;
result · 3 rows
player_idmessagesfirst_atlast_atreports
900012019:02:3820:56:186
900022019:04:2720:59:2610
900032019:06:0921:01:2013
Three accounts posted the same free-gems link 20 times each on 2026-10-30, from 19:02 to 21:01, spelled free, fre, gemz and GEMS. Both words within one edit find all 60 posts; players had reported the accounts 6, 10 and 13 times.

Checklist

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.

NeedHow SereneDB does itLooks like
Take in player eventsGame 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 timecount(DISTINCT player_id) per day or per minute counts each player once, however many events they send. Docs →count(DISTINCT player_id)
Follow install cohortsD1 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 funnelsbool_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 onlineA 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 chatAn 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 saidASOF 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 dashboardsGrafana'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 eventCOPY 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 dataA 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 $$ … $$

Architecture

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.

01 · ingest
COPY
game servers' events PostgreSQL :7890
01 · ingest
INSERT
installs · chat player reports
02 · store
events
one row per event
02 · store
players
one row per install
02 · store
chat
global and match chat
02 · store
reports
spam · abuse · cheating
03 · inverted index
chat_idx
messagetext
player_id, channelexact
tsrange
04 · count
count(DISTINCT)
players per day · FILTER
04 · cohorts
date_diff
D1 · D7 · funnels
04 · search
@@
BM25 · ts_levenshtein
04 · line up
ASOF JOIN
report → message
05 · history
CREATE JOB
Parquet · DELETE
06 · dashboards
Grafana
PostgreSQL data source :7890
> ingest · events

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.

sql
COPY events FROM STDIN;
2026-11-01 23:40:05 2400 240002 session_start \N
2026-11-01 23:52:05 2400 240002 match_end 1830
2026-11-01 23:58:05 2400 240002 session_end \N
\.
SELECT event, value FROM events WHERE session_id = 240002 ORDER BY ts;
-- COPY 3, then session_start · match_end 1830 · session_end
> ingest · chat and reports

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.

sql
SELECT reason, count(*) AS reports, count(DISTINCT player_id) AS players
FROM reports
GROUP BY reason ORDER BY reason;
-- abuse 9 from 2 · cheating 8 from 1 · spam 29 from 3
> stage 02 · events

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.

sql
SELECT event, count(*) AS events
FROM events
GROUP BY event ORDER BY events DESC;
-- match_end 18086 · session_start 9745 · session_end 9745
-- install 2400 · tutorial_done 1897 · purchase 191
> stage 02 · players

One row per install

The install time and the platform of each player. Cohorts, funnels and platform splits join the events to this table.

sql
SELECT platform, count(*) AS players
FROM players
GROUP BY platform ORDER BY platform;
-- android 1194 · ios 700 · pc 506
> stage 02 · chat

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.

sql
SELECT ts_lexize('chat_text', 'Tutorail step 3 wont load after 1.4.0');
-- {tutorail,step,3,wont,load,after,1.4.0}
SELECT unnest(ts_dict_agg(channel)) AS channel, unnest(ts_dict_count(channel)) AS messages
FROM chat_idx;
-- match 1471 · global 110, in no fixed order
> stage 02 · reports

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.

sql
SELECT reason, count(*) AS reports, count(DISTINCT player_id) AS players
FROM reports
GROUP BY reason ORDER BY reason;
-- abuse 9 from 2 · cheating 8 from 1 · spam 29 from 3
> stage 03 · inverted index

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).

sql
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);
> stage 04 · count

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.

sql
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;
> stage 04 · cohorts

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.

sql
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;
> stage 04 · search

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.

sql
SELECT message, count(*) AS posts, max(round(BM25(chat_idx.tableoid), 3)) AS score
FROM chat_idx
WHERE message @@ 'stuck tutorial'
GROUP BY message
ORDER BY score DESC, message
LIMIT 3;
-- reinstalled twice, still stuck in the tutorial · 5 posts · 3.159
-- stuck on tutorial step 3, the button does nothing · 4 posts · 2.738
-- tutorial broke after 1.4.0 on my phone · 8 posts · 1.34
> stage 04 · line up

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.

sql
SELECT r.ts AS reported_at, r.player_id, c.ts AS said_at, c.message
FROM reports r
ASOF JOIN chat c ON c.player_id = r.player_id AND r.ts >= c.ts
WHERE r.reason = 'abuse'
ORDER BY r.ts;
-- 10-23 11:24 · 386 · said 11:23:47: you are all trash lol
-- 10-24 15:24 · 1310 · said 15:23:25: uninstall noob
-- 10-25 15:22 · 1310 · said 15:20:37: report this noob, he keeps feeding
-- 10-25 16:51 · 386 · said 16:49:45: you are all trash lol
-- … 9 rows, one per abuse report
> stage 06 · dashboards

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.

sql
-- the panel, as written in Grafana:
-- SELECT $__timeGroupAlias(ts, '5m'), count(DISTINCT player_id) AS players, …
-- FROM events WHERE $__timeFilter(ts) GROUP BY 1 ORDER BY 1
-- what SereneDB receives for the range 20:00–20:30:
SELECT floor(extract(epoch from ts)/300)*300 AS "time",
count(DISTINCT player_id) AS players,
count(*) FILTER (WHERE event = 'match_end') AS matches
FROM events
WHERE ts BETWEEN '2026-11-01T20:00:00Z' AND '2026-11-01T20:30:00Z'
GROUP BY 1 ORDER BY 1;
-- time is epoch seconds, 1793563200 = 20:00 UTC; one row every 5 minutes
-- players 13 11 12 9 10 9 · matches 9 2 6 7 7 7
> stage 05 · history

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.

sql
COPY (SELECT *, ts::DATE AS day FROM events
WHERE ts < TIMESTAMP '2026-10-26 00:00:00')
TO '/tmp/events_history' (FORMAT parquet, PARTITION_BY (day), APPEND);
SELECT day, count(*) AS events, count(DISTINCT player_id) AS players
FROM read_parquet('/tmp/events_history/*/*.parquet', hive_partitioning = true)
WHERE day >= DATE '2026-10-23'
GROUP BY day ORDER BY day;
-- COPY 22857, then
-- 2026-10-23 | 3137 | 424
-- 2026-10-24 | 2966 | 411
-- 2026-10-25 | 2951 | 405
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';
$$;
EXECUTE JOB events_history;
SELECT job_name, last_status FROM sdb_jobs();
-- events_history | success
A diagram of six numbered stages. Events arrive from game servers with COPY over the PostgreSQL protocol on port 7890; installs, chat messages and player reports arrive as INSERTs. They are stored in four tables: events with one row per event, players with one row per install, chat for global and match chat, and reports for spam, abuse and cheating reports. The chat has an inverted index: the message through the chat_text dictionary, the player and the channel as exact terms, and the time as a range. Queries count players per day with count(DISTINCT player_id) and FILTER, follow install cohorts with date_diff for D1 and D7 retention and funnels, search the chat with @@ under BM25 or within an edit distance, and pair each report with the reported player's last message through ASOF JOIN. Grafana reads the results through its PostgreSQL data source, and a scheduled job copies old events to Parquet files and deletes them.

What player data needs from one engine

events · players · chat · reports
rows
Events are plain rows

No special table type: an event is a row, and a day of players is a count over its rows.

SELECT count(*) FROM events
typos
Misspellings still match

Chat matches within an edit distance, so tutorail and gemz are found, and BM25 puts the best matches first.

ts_levenshtein('tutorial', 1)
reports
Reports meet the chat

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_id
searchbench
A billion log rows

SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.

otel_logs_1b · 26.09.1
SearchBench on otel_logs_1b →

Build it

Tutorial: 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.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 \
  -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2

psql -h localhost -p 7890 -U postgres
Any PostgreSQL client connects the same way, on port 7890.
step 02 / 05 · sql
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);
CREATE TABLE four times, then the dictionary and the chat index. chat_text lowercases the messages and splits them into words; version numbers such as 1.4.0 stay whole.
step 03 / 05 · sql
-- 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;
2,400 players, 42,064 events, 1,581 chat messages and 46 reports, the same rows on every run. VACUUM (REFRESH_TABLE) makes the new messages searchable at once; the index otherwise refreshes every second.
step 04 / 05 · sql
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;
Under 1.4.0, none of the 138 Android players who installed finished the tutorial, against 90.1 % on iOS and PC. D1 retention falls to 18.3 % and 17.6 % for the installs of 2026-10-26 and 2026-10-27.
step 05 / 05 · sql
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';
$$;
50 complaints about the tutorial, 14 of them misspelled, all from Android players between 2026-10-26 and 2026-10-28. Three accounts posted the free-gems link 20 times each. The job runs every day at 02:00 UTC and moves events older than 90 days to Parquet.

Use cases

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.

built with
generate_seriesBETWEENcount(DISTINCT)
Real-time analytics →
example · players online, Sunday 2026-11-01
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;
result
atonline
18:00:004
18:30:0016
19:00:0011
19:30:0011
20:00:0015
20:30:0013
21:00:008
21:30:008
22:00:007
16 players at 18:30, the evening's peak, and 7 at 22:00.

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 %.

built with
LEFT JOINcount(DISTINCT)DECIMAL
OLAP database →
example · payer conversion by day 7
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;
result
cohortinstallspayers_d7payer_pctrevenue_per_install
2026-10-19554366.51.20
2026-10-2026072.70.60
2026-10-2120683.90.65
2026-10-2217763.40.65
2026-10-2315885.11.42
2026-10-2414442.80.42
2026-10-25134107.51.60
Seven install days with a full week of data. Amounts are in the store's currency.

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.

built with
medianHAVINGsubquery
Log analysis →
example · highest median scores since 2026-10-29
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;
result
player_idmatchesmedian_scorex_typicalreports
19061738942.68
1485623321.50
2246722551.50
Only player 1906 stands out, with 8 cheating reports. Players need 5 matches in the window to count.

FAQ

Frequently asked questions

Go to docs →

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

Get started with SereneDB

Start SereneDB, send your player events and chat, and query them in SQL from Grafana or any PostgreSQL client.