Drop theo từng level (tới L1000) — so version, lọc theo nguồn user, quốc gia, cohort cài app. Số thật từ BigQuery; định nghĩa mọi thứ ở tab Định nghĩa & Case.
Retention % chỉ tính khi lọc Cohort New user (nhóm cài trong A→B — thấy được từ lần chơi đầu). Công thức: user của level L ÷ user của level mốc 100%, trong đó level mốc 100% = level thấp nhất có data trong "Khoảng Level" (cũng là số ở KPI "Tổng user"); cả tử lẫn mẫu là user của CẢ level (mọi map), gộp mọi version đang bật (vì query theo dõi user xuyên version). Dòng con (map khác khi mở rộng) không có Retention. Drop during % / Drop after % = số user during/after ÷ số user của map đó (rê chuột vào ô để xem số user gốc); màu theo Ngưỡng "rủi ro cao": đỏ > ngưỡng, vàng > ½ ngưỡng, xanh còn lại, tính từ lần chơi đầu ở map đó: during = chưa hoàn thành level rồi nghỉ ≥ 2 ngày; after = đã hoàn thành level (ở map/version nào cũng được) nhưng không chơi level cao hơn rồi nghỉ ≥ 2 ngày (định nghĩa đầy đủ ở tab Định nghĩa & Case, mục 03).
| Level | Version | Level ID | Users | Retention % | Share % | Drop during % | Drop after % |
|---|
Toàn bộ nguồn dữ liệu nuôi tab Dashboard nằm ở đây: data mặc định (kết quả query thật 28/09, tự nạp khi mở trang) / chỗ nạp kết quả query mới, cách chạy query, level_map, từ điển cột + nguyên văn query. Tab Dashboard chỉ hiển thị kết quả đã lọc — tái sử dụng đúng dữ liệu từ đây, không tách riêng.
version, logical_level, mapping_users. Tối thiểu để Dashboard có bảng Drop: thêm mapped_level_thuc_te và drop_rate_mapping_pct (hoặc cặp confirmed/resolved). Nên có đủ (query mới ở mục E xuất đủ): mapped_level_thuc_te, unique_users_logical_level, confirmed_drop_users_mapping + resolved_users_mapping (để Drop % cộng chính xác khi chọn nhiều segment), drop_during_level_users/drop_after_level_users, country, media_source, first_open_cohort, và các cột ngày cohort_start_date…data_end_date (để Dashboard ghi rõ đang xem khoảng nào). Giữ MỌI dòng — kể cả sample_status khác OK và dòng chưa có Drop % — ngưỡng mẫu do Dashboard tự áp. File xuất từ query CŨ (có completed_users_mapping, chưa có level_continued_users) vẫn nạp được nhưng 2 cột Drop during/after sẽ hiện "—". File lớn (bản 28/09 ~480 MB) — dùng nút chọn file, không dán; nạp mất vài chục giây.Đã nạp sẵn level_map của 1.2.2 → 1.11 (GD cung cấp, mỗi version 1 khối riêng). Định dạng: dòng 1.9.2: (nhiều version dùng chung 1 danh sách thì viết được 1.2.2 và 1.3.2:) rồi các cặp logical-mapped cách nhau dấu phẩy. Level không khai = chính số level (vd L2 → 2) — đã đối chiếu data thật 1.9.2 / 1.10.4 / 1.11: map đông user nhất khớp đủ 1000/1000 level. Vùng lặp level của build cũ (1.6.4 từ L301, 1.7.1 từ L351, 1.8.13 từ L501) tự tính theo công thức Loop ở tab Định nghĩa & Case mục 04, không áp level_map. Khai trùng 1 level → lấy giá trị SAU (giống game nạp override). Dashboard so map đại diện (đông user nhất trong các map đủ ngưỡng mẫu) với Level ID ở đây: lệch thì hiện ⚠ cạnh số Level; khi mở rộng level, map đúng level_map có nhãn map mong muốn. Version không có trong danh sách (vd 1.12+) thì không cảnh báo. Rê chuột vào ⚠ để thấy cả 2: map muốn phát (level_map) và map game phát thật, kèm % user. Bản 1.0 → 1.5 chưa có event gd_play_level nên không có trong data (level_map của các bản này giữ lại để tham khảo).
media_source mới nhất — native set qua AnalyticServices.java:87 và được set lại mỗi lần mở app từ giá trị đã lưu (SyncPersistedUserAcquisitionProperty); (2) nếu thiếu, lấy từ tên event ua_source_<giá trị> đầu tiên; (3) không có cả 2 → unknown. Giá trị đã được app chuẩn hoá: chữ thường, tối đa 32 ký tự (vd organic, moloco…) — danh sách thật tự hiện trong filter sau khi nạp file.
Muốn gộp hết thành 1 dòng để giảm kích thước kết quả: đặt split_by_media = FALSE trong params_input (giá trị lúc đó là ALL).
start_date = ngày user đầu tiên chơi 1.6.4) → hôm chạy query − 2 ngày (data_end_date). Không còn C→D — luôn lấy toàn bộ hoạt động trong khoảng này.first_open trong khoảng data) được ghi đúng 1 kỳ = ngày bắt đầu kỳ (first_open_cohort, vd 2026-06-26), kỳ dài cohort_bucket_days ngày (mặc định 7, tính từ 19/06; đặt 1 = theo ngày nhưng file rất lớn). User cũ = old.clean/censored) vẫn nạp được, nhưng New user cố định theo A→B lúc chạy query cũ — Dashboard hiện băng cảnh báo nếu đổi A→B.exclude_countries = ['Vietnam'] trong params_input: loại cả user có nước = Vietnam, kể cả khi split_by_country = FALSE; file cũ vẫn có VN nên filter Dashboard giữ mặc định trừ VN) — lấy từ geo.country (GA4 có sẵn, suy từ IP). Gán theo USER, không theo event: mỗi user nhận đúng 1 nước = nước xuất hiện NHIỀU NHẤT trong các event của họ trong khoảng hoạt động (hoà thì theo chữ cái; NULL → UNKNOWN). Nhờ vậy bật nhiều nước cùng lúc không đếm trùng người. Chỉ giữ tên 10 nước đông user nhất (country_top_n = 10, xếp theo số user trong khoảng hoạt động, sau khi đã loại VN), các nước còn lại gộp thành OTHER để file nhỏ lại (đo trên file cũ: 10 nước này chiếm ~65% user, số dòng giảm ~⅔: 372k → ~115k). Đặt country_top_n = 0 để giữ đủ mọi nước. Muốn gộp hết thành 1 dòng: split_by_country = FALSE (giá trị lúc đó là ALL).
Query xuất 27 cột (đúng những cột Dashboard dùng — xem bảng A2). Tham số (ngày bắt đầu data, độ dài kỳ cài, version, level tối đa, tách country/media, ngưỡng) nằm hết trong params_input ở đầu query.
⚠ File khá lớn: bản 28/09 là 3,2 triệu dòng / 480 MB — nạp mất vài chục giây và tốn RAM trình duyệt. Muốn nhỏ hơn: giảm country_top_n, tăng cohort_bucket_days, tắt split_by_country/split_by_media, hoặc giới hạn report_versions.
| Cột | Kiểu | Ý nghĩa | Ví dụ |
|---|---|---|---|
_TABLE_SUFFIX | STRING | Phần ngày trong tên bảng — dùng để giới hạn đúng khoảng ngày quét | 20260907 |
event_timestamp | INT64 (micro giây) | Mốc thời gian CHÍNH XÁC của event — dùng để sắp thứ tự chuỗi hành vi 1 user | 1757123456789012 |
event_name | STRING | Tên loại event | gd_play_level |
user_pseudo_id | STRING | ID giả danh của 1 THIẾT BỊ/LẦN CÀI — xem mục C | 1a2b3c4d... |
app_info.version | STRING | Phiên bản app tại thời điểm bắn event | 1.10.4 |
event_params | ARRAY<STRUCT> | Danh sách tham số đi kèm event — xem mục D | [{key:"level", value:{int_value:123}}] |
event_bundle_sequence_id | INT64 | Số thứ tự gói event trong 1 lần gửi lên server — tie-break khi 2 event trùng event_timestamp | 42 |
batch_event_index | INT64 | Vị trí event trong batch gửi — tie-break tầng 2 | 3 |
geo.country | STRING | quốc gia suy ra từ IP, GA4 tự có sẵn. Cột country đầu ra = nước xuất hiện nhiều nhất của user (gán theo user); ngoài top country_top_n → 'OTHER'; user nước Vietnam bị loại hẳn. NULL → 'UNKNOWN' | Brazil |
first_open | event_name | event chuẩn GA4, bắn ĐÚNG 1 LẦN/user lúc mở app đầu tiên sau cài — dùng làm mốc cohort | — |
gd_complete_level | event_name | cùng tham số level/source_3 như gd_play_level (CONFIRMED). Dùng RIÊNG cho phễu Drop during/after — KHÔNG dùng cho phân loại Pass/Drop/Pending (mục 03 vẫn chỉ dựa gd_play_level) | — |
session_start, user_engagement | event_name | Chỉ để biết user còn hoạt động không (lần hoạt động cuối — mốc "im lặng ≥ 2 ngày") | — |
user_properties → media_source | STRING | Nguồn cài do app set (giá trị mới nhất của user) — nguồn chính cho cột media_source | moloco_int |
ua_source_<x> | event_name | Dự phòng khi thiếu user property: lấy <x> của event đầu tiên | ua_source_organic |
Mỗi dòng kết quả = version × logical_level × mapped_level × country × media_source × first_open_cohort. Danh sách này khớp đúng câu SELECT cuối của query ở mục E.
| # | Cột | Kiểu | Ý nghĩa | Ví dụ |
|---|---|---|---|---|
| 1 | version | STRING | Phiên bản app (app_info.version). Luôn bỏ build test 0.*/99.*; bản 1.0 → 1.5 không có (chưa có gd_play_level) | 1.11 |
| 2 | logical_level | INT64 | Level user nhìn thấy (tham số level của gd_play_level), 1 → max_report_level (1000) = cột Level trên Dashboard | 202 |
| 3 | mapped_level_thuc_te | STRING | Map user thật sự chơi (tham số source_3) = cột Level ID. Giá trị đặc biệt: OTHER_MAPS = gộp mọi map chiếm < min_map_share (1%) user của (version, level) — Dashboard hiện "Map lẻ (gộp)"; UNKNOWN = event thiếu source_3 — hiện "Không rõ map" | 1202 |
| 4 | country | STRING | Nước của user (nước xuất hiện nhiều nhất trong event của user, gán theo user). Ngoài top country_top_n (10) → OTHER; user nước Vietnam bị loại hẳn; tắt split_by_country → ALL | Brazil |
| 5 | media_source | STRING | nguồn cài của user (user property → event ua_source_* → unknown), mỗi user 1 giá trị. ALL khi tắt tách | organic |
| 6 | first_open_cohort | STRING | kỳ cài app: ngày bắt đầu kỳ (YYYY-MM-DD) nếu first_open nằm trong khoảng data, ngược lại 'old' (user cũ). Gán theo user. (File query cũ: 'clean'/'censored') | 2026-06-26 |
| 7 | unique_users_logical_level | INT64 | User DISTINCT chơi level đó (mọi map) trong đúng version + segment — lặp lại y hệt trên mọi map của level. Mẫu số của Share % và Retention % | 1200 |
| 8 | mapping_users | INT64 | User DISTINCT chơi đúng map đó = cột "Users" trên Dashboard | 1000 |
| 9 | mapping_share_pct | FLOAT64 | mapping_users ÷ unique_users_logical_level × 100 của TỪNG dòng segment. Dashboard không dùng trực tiếp — khi cộng nhiều segment tự tính lại Share % = Σ user map ÷ Σ user level | 83.33 |
| 10 | mapping_count_at_level | INT64 | Số map khác nhau ở (version, level) — không tách segment; OTHER_MAPS tính là 1 map | 3 |
| 11 | confirmed_drop_users_mapping | INT64 | Số Confirmed Drop / số đã có kết quả (Pass + Confirmed Drop). Drop % = confirmed / resolved. Dashboard cộng 2 cột này khi gộp nhiều segment | 40 / 900 |
| 12 | resolved_users_mapping | INT64 | User đã có kết quả (Pass + Confirmed Drop) — mẫu số của Drop %. Pending không tính | 900 |
| 13 | drop_rate_mapping_pct | FLOAT64 | confirmed ÷ resolved × 100 của từng dòng. Khi cộng segment Dashboard dùng Σ confirmed ÷ Σ resolved (không lấy trung bình cột %) | 5.56 |
| 14 | pending_share_pct | FLOAT64 | pending ÷ outcome_users × 100 — tỷ lệ user có outcome nhưng còn chờ (chưa đủ 2 ngày im lặng). Dùng cho sample_status (HIGH_PENDING) | 4.2 |
| 15 | outcome_coverage_pct | FLOAT64 | outcome_users ÷ mapping_users × 100 — tỷ lệ user của map nhận outcome (lần chơi cuối tại level ở map này). Dùng cho sample_status (LOW_COVERAGE) | 88.0 |
| 16 | level_completed_users | INT64 | đã complete HOẶC đã lên level cao hơn (tham khảo) | 880 |
| 17 | level_continued_users | INT64 | phễu: sau lần chơi đầu ở dòng này, user có chơi level cao hơn | 850 |
| 18 | drop_during_level_users | INT64 | chưa complete, chưa lên level cao hơn, và đã im lặng ≥ 2 ngày | 70 |
| 19 | drop_after_level_users | INT64 | đã gd_complete_level level đó nhưng không lên level cao hơn, và đã im lặng ≥ 2 ngày | 30 |
| 20 | drop_pending_level_users | INT64 | chưa lên level cao hơn nhưng vẫn còn hoạt động trong 2 ngày cuối (chưa kết luận). continued + after + during + pending = mapping_users | 50 |
| 21 | sample_status | STRING | Nhãn chất lượng tổng hợp của SQL, xét lần lượt: UNKNOWN_MAPPING (không đọc được map) → LOW_SAMPLE (mapping_users < min_mapping_users 100, hoặc resolved < min_resolved_users 100) → LOW_COVERAGE (coverage < 0.50) → HIGH_PENDING (pending > 0.20) → OK. Ngưỡng sửa được trong params_input. Nhãn tính theo TỪNG dòng segment nên khi tách country/media rất nhiều dòng thành LOW_SAMPLE. Dashboard không lọc theo cột này — chỉ hiện trong tooltip nhãn Low sample | LOW_SAMPLE |
| 22 | cohort_start_date | DATE | Ngày bắt đầu khoảng data (start_date = 19/06/2026, ngày user đầu tiên chơi 1.6.4) — mốc tính kỳ cài app, sàn của ô A | 2026-06-19 |
| 23 | cohort_end_date | DATE | Ngày cuối xét first_open (= data_end_date) — trần của ô B | 2026-09-26 |
| 24 | activity_start_date | DATE | Ngày bắt đầu xét hoạt động (= start_date) | 2026-06-19 |
| 25 | activity_end_date | DATE | Ngày cuối xét hoạt động (= data_end_date) — mốc "im lặng ≥ 2 ngày" | 2026-09-26 |
| 26 | data_end_date | DATE | Hôm chạy query − 2 ngày (tự tính) | 2026-09-26 |
| 27 | cohort_bucket_days | INT64 | độ dài 1 kỳ cài app (ngày), để Dashboard làm tròn A→B | 7 |
user_pseudo_id là gìCOUNT(DISTINCT user_pseudo_id) trong toàn bộ query dùng để ra số "unique users" — kể cả unique_users_logical_level, mapping_users, mọi thứ liên quan tới "bao nhiêu người".user_pseudo_id từ GA4.event_params — vì sao phải UNNEST + COALESCE 4 kiểuGA4 lưu mọi tham số dưới dạng mảng [{key, value}], và value là 1 STRUCT CHUNG có sẵn 4 field: string_value, int_value, float_value, double_value — chỉ ĐÚNG 1 trong 4 field có giá trị thật, 3 field còn lại NULL, tuỳ kiểu dữ liệu client gửi lên. Vì vậy mọi lần lấy giá trị trong query đều phải viết dạng:
Query Drop Level Data dùng đúng công thức này để lấy 2 giá trị: level (→ logical_level) và source_3 (→ mapped_level).
Query chạy dạng SCRIPT (nhiều câu lệnh, bấm Run 1 lần như thường): params, first_open_users, raw, user_last_activity, play_events_raw, play_events được lưu thành bảng tạm (TEMP TABLE, sắp sẵn theo user) — bảng event gốc chỉ quét 2 lần (first_open_users + raw); tín hiệu theo user (hoạt động cuối, nguồn user) gộp 1 lượt ở user_last_activity. Đã so bằng máy: 31/31 bước + câu SELECT cuối giống hệt bản 1 câu WITH (chỉ đổi cách chạy, không đổi phép tính). Bản 1 câu WITH trước đây bị BigQuery tính lại CTE mỗi lần gọi (473 stage, chạy > 15 phút). Kết quả là câu SELECT cuối — trong BigQuery bấm vào bước cuối để xem / tải kết quả.
3 nhánh chạy song song rồi gộp ở metrics_base: (a) đếm user (population), (b) phễu Drop during/after theo TỪNG USER, (c) phân loại Pass/Drop/Pending (logic cũ, không đổi). Segment (country/media/cohort) được gán 1 lần theo user ở đầu rồi đi theo mọi nhánh.
| # | CTE | Việc chính |
|---|---|---|
| 1 | params_input | Mọi tham số sửa tay: ngày bắt đầu data (19/06), độ dài kỳ cài (cohort_bucket_days), version, nước loại trừ, số nước giữ tên (country_top_n), ngưỡng gộp map lẻ (min_map_share), level tối đa (1000), tách country/media, ngưỡng |
| 2 | params | Khoảng data: obs_start/obs_end = start_date → data_end_date |
| 3 | first_open_users | Ngày first_open đầu tiên của user mới trong khoảng data (không tính first_open của build test 0.*/99.*) → dùng để gắn kỳ cài |
| 4 | raw | Quét thô gd_play_level, gd_complete_level (bản 1.0 → 1.5 chưa có 2 event này nên không có trong kết quả), session_start, user_engagement, ua_source_* trong khoảng hoạt động — bỏ hẳn mọi event của build test 0.*/99.* (vd 0.0.1, 99.0) để chúng không được tính là tiến trình/complete/hoạt động; đọc level/source_3, geo.country, user property media_source |
| 5 | user_last_activity | 1 lượt trên raw, mỗi user 1 dòng: lần hoạt động cuối (để phân biệt im lặng ≥ 2 ngày) + nguồn user (media_source mới nhất, dự phòng ua_source_* đầu tiên) |
| 6 | user_country_mode | Nước xuất hiện nhiều nhất / user (đếm chính xác) |
| 7 | country_top | Top country_top_n nước đông user nhất (sau khi loại exclude_countries; hoà theo chữ cái). Nước ngoài top → OTHER |
| 8 | user_segments | Gán mỗi user đúng 1 country (ngoài top → OTHER), 1 media_source, 1 kỳ cài (hoặc old); loại hẳn user có country thuộc exclude_countries (mặc định Vietnam) |
| 9 | play_events_raw | gd_play_level level 1 → max+1 (giữ dư 1 level để level cuối vẫn thấy level kế), gắn segment |
| 10 | map_share | Tỷ lệ user (distinct, mọi segment) của mỗi map trong (version, level) |
| 11 | play_events | Map có tỷ lệ < min_map_share (1%) → OTHER_MAPS; mọi nhánh sau đọc từ đây |
| 12 | complete_events | gd_complete_level (user, level, thời điểm) |
| 13 | version_scope | Version được xuất: mọi version hoặc danh sách trong report_versions |
| 14 | logical_population | User distinct theo version × level × segment |
| 15 | mapping_population | User distinct + lượt chơi theo version × level × map × segment |
| 16 | mapping_count | Số map khác nhau ở mỗi level |
| 17 | play_future | (b) Level cao nhất user chơi SAU mỗi lần chơi |
| 18 | exposure_first | (b) Lần chơi đầu của user ở mỗi dòng (version × level × map) — mốc của phễu |
| 19 | complete_last | (b) Lần complete cuối của user ở mỗi level |
| 20 | exposure_outcome | (b) Cờ theo user: đã lên level cao hơn? đã complete sau mốc? đã im lặng ≥ 2 ngày? |
| 21 | funnel_summary | (b) Đếm 4 nhóm không giao nhau: continued / drop_after / drop_during / pending |
| 22 | first_observed_play | (c) Lần chơi đầu tiên quan sát được / user (left-censoring) |
| 23 | play_with_previous_max | (c) Level cao nhất đã đạt TRƯỚC mỗi lần chơi |
| 24 | frontier_play | (c) Chỉ giữ tiến trình mới / retry tại max — loại replay + left-censor |
| 25 | level_owner | (c) 1 dòng / user / level = lần chơi CUỐI tại frontier (outcome owner) |
| 26 | owner_with_next | (c) LEAD tìm level / version kế tiếp (theo user, xuyên version) |
| 27 | classified | (c) Gắn nhãn Pass / Cross-version Pass / Confirmed Drop / Pending |
| 28 | outcome_summary | (c) Đếm user theo nhãn, theo version × level × map × segment |
| 29 | metrics_base | Gộp (a) + (b) + (c) theo đủ khoá version × level × map × country × media × cohort |
| 30 | metrics | Tính drop_rate, pending_share, outcome_coverage (share % tính ở SELECT cuối) |
| 31 | quality | Gắn sample_status (tham khảo — Dashboard dùng ngưỡng riêng chỉnh được) |
Query lấy . Các tham số khác (version, level tối đa, tách country/media, ngưỡng) sửa trực tiếp trong CTE params_input sau khi copy.
/* ============================================================
DROP PERFORMANCE BY EXACT MAPPED LEVEL — v3
MỤC TIÊU
------------------------------------------------------------
Tìm actual level / source_3 tốt nhất tại mỗi logical level,
kèm phễu Drop during / Drop after theo TỪNG USER.
1 ROW =
VERSION x LOGICAL_LEVEL x MAPPED_LEVEL
x COUNTRY x MEDIA_SOURCE x FIRST_OPEN_COHORT
THAM SỐ (sửa ở CTE params_input)
------------------------------------------------------------
- report_versions: [] = MỌI version, hoặc liệt kê cụ thể
vd ['1.9.2','1.10.4','1.11'].
- Bản 1.0 → 1.5 CHƯA có gd_play_level / gd_complete_level => không
có trong kết quả (query chỉ đọc event gd_*).
- LUÔN LOẠI (kể cả khi liệt kê version):
build test 0.* / 99.* (vd 0.0.1, 99.0),
user có country = exclude_countries (mặc định 'Vietnam').
- max_report_level: level cao nhất xuất ra (mặc định 1000).
Tầng theo dõi tiến trình tự giữ tới max_report_level + 1
để level cuối cùng vẫn nhìn thấy level kế tiếp.
- split_by_country / split_by_media: TRUE = tách dòng theo chiều
đó, FALSE = gộp thành 'ALL' (giảm mạnh số dòng + LOW_SAMPLE).
- country_top_n: chỉ giữ tên N nước đông user nhất (mặc định 10),
các nước còn lại gộp thành 'OTHER' => giảm mạnh số dòng.
0 = giữ đủ mọi nước.
- min_map_share: map chiếm < ngưỡng này (mặc định 0.01 = 1%)
user của (version, level) gộp thành 1 map 'OTHER_MAPS'
=> bớt rất nhiều dòng map lẻ tẻ; tổng user giữ đúng.
0 = giữ đủ mọi map.
- start_date: ngày bắt đầu lấy data = ngày user ĐẦU TIÊN chơi 1.6.4
(19/06/2026). Data lấy từ start_date -> data_end_date.
- data_end_date = ngày chạy query - 2 ngày (tự tính).
- cohort_bucket_days: độ dài 1 KỲ CÀI APP (mặc định 7 ngày, 1 = theo
ngày). Mỗi user mới ghi đúng 1 kỳ (ngày first_open) => dashboard
tự cộng các kỳ nằm trong A->B, KHÔNG cần chạy lại query khi đổi A->B.
CHIỀU PHÂN KHÚC — GÁN THEO TỪNG USER (mỗi user đúng 1 giá trị)
------------------------------------------------------------
- country: quốc gia xuất hiện NHIỀU NHẤT (đếm chính xác, hoà thì
theo chữ cái) trong các event của user trong cửa sổ. Gán theo user (không theo event) để mỗi user chỉ
thuộc 1 country => cộng nhiều country không đếm trùng.
Ngoài top country_top_n => 'OTHER'.
- media_source: Firebase user property 'media_source' (đã sanitize,
native AnalyticServices.java:87 + backfill
SyncPersistedUserAcquisitionProperty), dự phòng event
'ua_source_<x>', không có => 'unknown'.
- first_open_cohort: ngày BẮT ĐẦU kỳ cài app của user ('YYYY-MM-DD',
kỳ tính từ start_date, dài cohort_bucket_days ngày) nếu first_open
nằm trong [start_date, data_end_date]; ngược lại 'old' (user cũ).
DROP LOGIC (phân loại Pass/Drop/Pending — KHÔNG đổi)
------------------------------------------------------------
PASS = sau khi chơi logical level X, user về sau reach level > X.
Follow user XUYÊN VERSION.
1.9.2 L123 -> update -> 1.10.4 L124 => L123 = PASS,
KHÔNG tính false Drop cho 1.9.2.
OUTCOME OWNER = exact mapping CUỐI CÙNG user chơi tại frontier
level X trước khi progress hoặc drop.
CONFIRMED DROP = không có level cao hơn sau đó
VÀ activity cuối <= obs_end - inactive_days.
PENDING = không có level cao hơn
NHƯNG activity cuối nằm trong inactive_days cuối.
DROP RATE = confirmed_drop / (passed + confirmed_drop).
Pending KHÔNG vào denominator.
PHỄU DROP DURING / AFTER (theo TỪNG USER, trên mỗi dòng)
------------------------------------------------------------
Với mỗi user có chơi dòng R (version, level X, map, segment),
lấy lần chơi ĐẦU TIÊN của user ở R làm mốc:
progressed = sau mốc đó user có gd_play_level ở level > X
completed_ev = sau mốc đó user có gd_complete_level ở level X
(bất kể map/version — đúng tinh thần xuyên version)
passed = completed_ev OR progressed
Chia user của R thành 4 nhóm KHÔNG GIAO NHAU:
continued = progressed
drop_after (confirmed) = completed_ev AND NOT progressed AND inactive
drop_during (confirmed) = NOT passed AND inactive
pending = NOT progressed AND còn active
=> mapping_users = continued + drop_after + drop_during + pending
=> cả 4 luôn >= 0 (đều là tập con của cùng 1 nhóm user).
"inactive" = activity cuối <= obs_end - inactive_days (giống
Confirmed Drop), để user vừa chơi hôm qua không bị tính rớt oan.
⚠ gd_complete_level có thể bắn GIẢ từ GameplayTool (remote config
game_tool) — nếu config đó bật rộng, completed_ev bị thổi phồng
=> drop_during bị đánh giá THẤP hơn thực tế.
LƯU Ý SO KHỚP VỚI RPU
------------------------------------------------------------
RPU không tách theo segment. Vì mỗi user chỉ thuộc đúng 1
country / 1 media / 1 cohort, SUM các segment của cùng
(version, level, map) KHỚP đúng số distinct user tổng.
============================================================ */
/* ============================================================
CHẠY DẠNG SCRIPT (nhiều câu lệnh) — bấm Run 1 lần như bình thường.
Bảng event gốc chỉ được quét 2 LẦN (first_open_users + raw) rồi lưu
vào bảng tạm (TEMP TABLE); các bước sau đọc từ bảng tạm => nhanh hơn
nhiều so với 1 câu WITH dài (BigQuery tính lại CTE mỗi lần được gọi).
Tín hiệu theo user (hoạt động cuối, nguồn user) gộp 1 lần ở
user_last_activity thay vì đọc lại toàn bộ raw nhiều lần. Kết quả = câu SELECT
cuối cùng (tab Results của bước cuối).
============================================================ */
CREATE TEMP TABLE params AS
WITH params_input AS (
SELECT
/* Ngày bắt đầu lấy data = ngày user đầu tiên chơi 1.6.4 */
DATE '2026-06-19'
AS start_date,
/* Độ dài 1 kỳ cài app (ngày). 1 = từng ngày (file rất lớn) */
7
AS cohort_bucket_days,
/* Ngày cuối lấy data = hôm nay - 2 ngày */
DATE_SUB(
CURRENT_DATE('Asia/Ho_Chi_Minh'),
INTERVAL 2 DAY
) AS data_end_date,
2
AS inactive_days,
/* [] = mọi version. Build test 0.* / 99.* LUÔN bị loại (ở raw) */
CAST([] AS ARRAY<STRING>)
AS report_versions,
/* LUÔN loại user có country (gán theo user) thuộc danh sách này */
['Vietnam']
AS exclude_countries,
/* Giữ tên N nước đông user nhất, còn lại => 'OTHER'. 0 = giữ đủ */
10
AS country_top_n,
/* Map < tỷ lệ này user của (version, level) => 'OTHER_MAPS'. 0 = giữ đủ */
0.01
AS min_map_share,
1000
AS max_report_level,
TRUE
AS split_by_country,
TRUE
AS split_by_media,
/* Reliability */
100
AS min_mapping_users,
100
AS min_resolved_users,
0.50
AS min_outcome_coverage,
0.20
AS max_pending_share
)
SELECT
*,
start_date
AS obs_start,
data_end_date
AS obs_end
FROM params_input;
/* ============================================================
0. FIRST OPEN — ngày cài app đầu tiên của user mới
(first_open trong [start_date, data_end_date])
============================================================ */
CREATE TEMP TABLE first_open_users AS
SELECT
user_pseudo_id,
MIN(
PARSE_DATE('%Y%m%d', _TABLE_SUFFIX)
) AS first_open_date
FROM
`r2368g168-and-water-sort-3d.analytics_527788107.events_intraday_*`
CROSS JOIN params p
WHERE
_TABLE_SUFFIX BETWEEN
FORMAT_DATE(
'%Y%m%d',
p.start_date
)
AND
FORMAT_DATE(
'%Y%m%d',
p.obs_end
)
AND event_name = 'first_open'
/* first_open từ build test 0.* / 99.* không tính là New user */
AND NOT REGEXP_CONTAINS(
COALESCE(app_info.version, ''),
r'^(0|99)\.'
)
GROUP BY
user_pseudo_id;
/* ============================================================
1. RAW SIGNALS — trong cửa sổ quan sát [obs_start, obs_end]
gd_play_level : progression / mapping
gd_complete_level : phễu Drop during/after
session_start +
user_engagement : user có quay lại game không
ua_source_* : media_source dự phòng
KHÔNG filter version ở đây (trừ build test 0.* / 99.*):
phải follow user xuyên version.
============================================================ */
CREATE TEMP TABLE raw
CLUSTER BY user_pseudo_id AS
SELECT
PARSE_DATE(
'%Y%m%d',
_TABLE_SUFFIX
) AS data_date,
event_timestamp,
event_bundle_sequence_id,
batch_event_index,
user_pseudo_id,
event_name,
app_info.version
AS version,
COALESCE(
geo.country,
'UNKNOWN'
) AS event_country,
(
SELECT up.value.string_value
FROM UNNEST(user_properties) up
WHERE up.key = 'media_source'
LIMIT 1
) AS up_media_source,
CASE
WHEN event_name IN ('gd_play_level', 'gd_complete_level')
THEN (
SELECT
COALESCE(
SAFE_CAST(
ep.value.string_value
AS INT64
),
ep.value.int_value,
SAFE_CAST(
ep.value.float_value
AS INT64
),
SAFE_CAST(
ep.value.double_value
AS INT64
)
)
FROM UNNEST(event_params) ep
WHERE ep.key = 'level'
LIMIT 1
)
END AS logical_level,
CASE
WHEN event_name IN ('gd_play_level', 'gd_complete_level')
THEN (
SELECT
COALESCE(
ep.value.string_value,
CAST(
ep.value.int_value
AS STRING
),
CAST(
ep.value.float_value
AS STRING
),
CAST(
ep.value.double_value
AS STRING
)
)
FROM UNNEST(event_params) ep
WHERE ep.key = 'source_3'
LIMIT 1
)
END AS mapped_level
FROM
`r2368g168-and-water-sort-3d.analytics_527788107.events_intraday_*`
CROSS JOIN params p
WHERE
_TABLE_SUFFIX BETWEEN
FORMAT_DATE(
'%Y%m%d',
p.obs_start
)
AND
FORMAT_DATE(
'%Y%m%d',
p.obs_end
)
AND user_pseudo_id IS NOT NULL
/* Build test 0.* / 99.* (vd 0.0.1, 99.0): bỏ MỌI event ngay từ
đây — không chỉ ẩn khỏi output — để event build test không
được tính là tiến trình / complete / hoạt động của user */
AND NOT REGEXP_CONTAINS(
COALESCE(app_info.version, ''),
r'^(0|99)\.'
)
AND (
event_name IN (
'gd_play_level',
'gd_complete_level',
'session_start',
'user_engagement'
)
OR STARTS_WITH(event_name, 'ua_source_')
);
/* ============================================================
1a. TÍN HIỆU / USER — trong cửa sổ quan sát, gộp 1 lượt trên raw:
- last_activity_date: lần hoạt động cuối (im lặng ≥ N ngày)
- media_up / media_ua: nguồn user (dùng ở user_segments)
============================================================ */
CREATE TEMP TABLE user_last_activity
CLUSTER BY user_pseudo_id AS
SELECT
user_pseudo_id,
MAX(
data_date
) AS last_activity_date,
/* user property media_source MỚI NHẤT */
ARRAY_AGG(
NULLIF(up_media_source, '') IGNORE NULLS
ORDER BY event_timestamp DESC
LIMIT 1
)[SAFE_OFFSET(0)] AS media_up,
/* dự phòng: event ua_source_<x> ĐẦU TIÊN */
ARRAY_AGG(
IF(
STARTS_WITH(event_name, 'ua_source_'),
NULLIF(SUBSTR(event_name, 11), ''),
NULL
) IGNORE NULLS
ORDER BY event_timestamp
LIMIT 1
)[SAFE_OFFSET(0)] AS media_ua
FROM raw
GROUP BY
user_pseudo_id;
CREATE TEMP TABLE play_events_raw
CLUSTER BY user_pseudo_id AS
WITH
/* ============================================================
1b0. COUNTRY / USER — country xuất hiện NHIỀU NHẤT (đếm chính xác),
hoà thì lấy theo thứ tự chữ cái để kết quả ổn định giữa các lần chạy.
============================================================ */
user_country_mode AS (
SELECT
user_pseudo_id,
event_country
AS country_mode
FROM raw
GROUP BY
user_pseudo_id,
event_country
QUALIFY
ROW_NUMBER() OVER (
PARTITION BY
user_pseudo_id
ORDER BY
COUNT(*) DESC,
event_country
) = 1
),
/* ============================================================
1b1. TOP COUNTRY — N nước đông user nhất (sau khi loại
exclude_countries); hoà thì theo chữ cái cho ổn định.
============================================================ */
country_top AS (
SELECT
r.country_mode
FROM (
SELECT
uc.country_mode,
ROW_NUMBER() OVER (
ORDER BY
COUNT(*) DESC,
uc.country_mode
) AS country_rank
FROM user_country_mode uc
CROSS JOIN params p
WHERE
uc.country_mode
NOT IN UNNEST(p.exclude_countries)
GROUP BY
uc.country_mode
) r
CROSS JOIN params p
WHERE
p.country_top_n <= 0
OR r.country_rank <= p.country_top_n
),
/* ============================================================
1b. USER SEGMENTS — 1 dòng / user, mỗi chiều đúng 1 giá trị
============================================================ */
user_segments AS (
/* 1 dòng / user: user_country_mode và user_last_activity đều đúng
1 dòng / user của raw; first_open_users DISTINCT; country_top
1 dòng / nước => không nhân dòng, không cần GROUP BY lại raw. */
SELECT
uc.user_pseudo_id,
IF(
p.split_by_country,
IF(
ct.country_mode IS NOT NULL,
uc.country_mode,
'OTHER'
),
'ALL'
) AS country,
IF(
p.split_by_media,
COALESCE(
ua.media_up,
ua.media_ua,
'unknown'
),
'ALL'
) AS media_source,
/* Kỳ cài app: ngày bắt đầu kỳ (tính từ start_date) — user cũ = 'old' */
IF(
fo.user_pseudo_id IS NOT NULL,
CAST(
DATE_ADD(
p.start_date,
INTERVAL DIV(
DATE_DIFF(fo.first_open_date, p.start_date, DAY),
p.cohort_bucket_days
) * p.cohort_bucket_days DAY
) AS STRING
),
'old'
) AS first_open_cohort
FROM user_country_mode uc
CROSS JOIN params p
JOIN user_last_activity ua
ON ua.user_pseudo_id
= uc.user_pseudo_id
LEFT JOIN first_open_users fo
ON fo.user_pseudo_id
= uc.user_pseudo_id
LEFT JOIN country_top ct
ON ct.country_mode
= uc.country_mode
/* Loại cả user (mọi event của họ), không loại từng event,
để tiến trình của user còn lại không bị cắt */
WHERE
uc.country_mode
NOT IN UNNEST(p.exclude_countries)
)
/* ============================================================
2. PLAY EVENTS — tới max_report_level + 1
Giữ dư 1 level để level cuối vẫn thấy level kế tiếp
(vd max = 1000 thì L1000 -> L1001 vẫn = PASS).
============================================================ */
SELECT
r.data_date,
r.event_timestamp,
r.event_bundle_sequence_id,
r.batch_event_index,
r.user_pseudo_id,
r.version,
r.logical_level,
COALESCE(
r.mapped_level,
'UNKNOWN'
) AS mapped_level,
s.country,
s.media_source,
s.first_open_cohort
FROM raw r
JOIN user_segments s
ON s.user_pseudo_id
= r.user_pseudo_id
CROSS JOIN params p
WHERE
r.event_name = 'gd_play_level'
AND r.version IS NOT NULL
AND r.logical_level
BETWEEN 1 AND p.max_report_level + 1;
CREATE TEMP TABLE play_events
CLUSTER BY user_pseudo_id AS
WITH
/* ============================================================
2a. MAP LẺ — map chiếm < min_map_share user (distinct) của
(version, level) gộp thành 'OTHER_MAPS'. Tính trên TOÀN BỘ
user (mọi segment) để 1 map gộp hay không là như nhau ở mọi
segment => cộng segment vẫn khớp. Tổng user không đổi.
============================================================ */
map_share AS (
SELECT
m.version,
m.logical_level,
m.mapped_level,
SAFE_DIVIDE(
m.map_users,
l.level_users
) AS map_share
FROM (
SELECT
version,
logical_level,
mapped_level,
COUNT(DISTINCT user_pseudo_id) AS map_users
FROM play_events_raw
GROUP BY
version,
logical_level,
mapped_level
) m
JOIN (
SELECT
version,
logical_level,
COUNT(DISTINCT user_pseudo_id) AS level_users
FROM play_events_raw
GROUP BY
version,
logical_level
) l
ON l.version = m.version
AND l.logical_level = m.logical_level
)
SELECT
pe.* REPLACE (
IF(
ms.map_share < p.min_map_share,
'OTHER_MAPS',
pe.mapped_level
) AS mapped_level
)
FROM play_events_raw pe
JOIN map_share ms
ON ms.version = pe.version
AND ms.logical_level = pe.logical_level
AND ms.mapped_level = pe.mapped_level
CROSS JOIN params p;
WITH
/* ============================================================
2b. COMPLETE EVENTS — chỉ cần user + level + thời điểm
============================================================ */
complete_events AS (
SELECT
r.user_pseudo_id,
r.logical_level,
r.event_timestamp
FROM raw r
CROSS JOIN params p
WHERE
r.event_name = 'gd_complete_level'
AND r.logical_level
BETWEEN 1 AND p.max_report_level + 1
),
/* ============================================================
2c. VERSION SCOPE — version nào được xuất ra
============================================================ */
version_scope AS (
SELECT DISTINCT
pe.version
FROM play_events pe
CROSS JOIN params p
WHERE
ARRAY_LENGTH(p.report_versions) = 0
OR pe.version IN UNNEST(p.report_versions)
),
/* ============================================================
3. LOGICAL LEVEL POPULATION
============================================================ */
logical_population AS (
SELECT
pe.version,
pe.logical_level,
pe.country,
pe.media_source,
pe.first_open_cohort,
COUNT(
DISTINCT pe.user_pseudo_id
) AS unique_users_logical_level
FROM play_events pe
JOIN version_scope vs
ON vs.version = pe.version
CROSS JOIN params p
WHERE
pe.logical_level
BETWEEN 1 AND p.max_report_level
GROUP BY
pe.version,
pe.logical_level,
pe.country,
pe.media_source,
pe.first_open_cohort
),
/* ============================================================
4. EXACT MAPPING EXPOSURE
============================================================ */
mapping_population AS (
SELECT
pe.version,
pe.logical_level,
pe.mapped_level,
pe.country,
pe.media_source,
pe.first_open_cohort,
COUNT(
DISTINCT pe.user_pseudo_id
) AS mapping_users,
COUNT(*)
AS play_events_mapping
FROM play_events pe
JOIN version_scope vs
ON vs.version = pe.version
CROSS JOIN params p
WHERE
pe.logical_level
BETWEEN 1 AND p.max_report_level
GROUP BY
pe.version,
pe.logical_level,
pe.mapped_level,
pe.country,
pe.media_source,
pe.first_open_cohort
),
/* ============================================================
5. MAPPING COUNT / LEVEL — số map khác nhau (không đếm segment)
============================================================ */
mapping_count AS (
SELECT
version,
logical_level,
COUNT(
DISTINCT mapped_level
) AS mapping_count_at_level
FROM mapping_population
GROUP BY
version,
logical_level
),
/* ============================================================
5b. PHỄU DURING/AFTER — timeline từng user
future_max_play_level = level cao nhất user chơi SAU event này.
============================================================ */
play_future AS (
SELECT
pe.*,
MAX(pe.logical_level) OVER (
PARTITION BY
pe.user_pseudo_id
ORDER BY
pe.event_timestamp,
COALESCE(pe.event_bundle_sequence_id, 0),
COALESCE(pe.batch_event_index, 0)
ROWS BETWEEN
1 FOLLOWING
AND UNBOUNDED FOLLOWING
) AS future_max_play_level
FROM play_events pe
),
/* Lần chơi ĐẦU TIÊN của mỗi user trên mỗi dòng R. */
exposure_first AS (
SELECT
pf.*
FROM play_future pf
JOIN version_scope vs
ON vs.version = pf.version
CROSS JOIN params p
WHERE
pf.logical_level
BETWEEN 1 AND p.max_report_level
QUALIFY
ROW_NUMBER() OVER (
PARTITION BY
pf.user_pseudo_id,
pf.version,
pf.logical_level,
pf.mapped_level
ORDER BY
pf.event_timestamp,
COALESCE(pf.event_bundle_sequence_id, 0),
COALESCE(pf.batch_event_index, 0)
) = 1
),
/* Lần complete CUỐI của user tại mỗi level — có complete sau
lần chơi đầu <=> last_complete_ts >= first_play_ts. */
complete_last AS (
SELECT
user_pseudo_id,
logical_level,
MAX(event_timestamp)
AS last_complete_ts
FROM complete_events
GROUP BY
user_pseudo_id,
logical_level
),
exposure_outcome AS (
SELECT
ef.version,
ef.logical_level,
ef.mapped_level,
ef.country,
ef.media_source,
ef.first_open_cohort,
ef.user_pseudo_id,
COALESCE(
ef.future_max_play_level > ef.logical_level,
FALSE
) AS progressed,
COALESCE(
cl.last_complete_ts >= ef.event_timestamp,
FALSE
) AS completed_ev,
ula.last_activity_date
<= DATE_SUB(
p.obs_end,
INTERVAL p.inactive_days DAY
) AS is_inactive
FROM exposure_first ef
LEFT JOIN complete_last cl
ON cl.user_pseudo_id = ef.user_pseudo_id
AND cl.logical_level = ef.logical_level
JOIN user_last_activity ula
ON ula.user_pseudo_id = ef.user_pseudo_id
CROSS JOIN params p
),
funnel_summary AS (
SELECT
version,
logical_level,
mapped_level,
country,
media_source,
first_open_cohort,
COUNT(*)
AS funnel_users,
COUNTIF(
completed_ev OR progressed
) AS level_completed_users,
COUNTIF(
progressed
) AS level_continued_users,
COUNTIF(
NOT completed_ev
AND NOT progressed
AND is_inactive
) AS drop_during_level_users,
COUNTIF(
completed_ev
AND NOT progressed
AND is_inactive
) AS drop_after_level_users,
COUNTIF(
NOT progressed
AND NOT is_inactive
) AS drop_pending_level_users
FROM exposure_outcome
GROUP BY
version,
logical_level,
mapped_level,
country,
media_source,
first_open_cohort
),
/* ============================================================
6. FIRST OBSERVED PLAY / USER — xử lý LEFT CENSORING
Lần đầu thấy user đã ở L250 => không biết L250 là progression
thật hay replay => không dùng chính level đó để assign Drop.
Từ L251+ dùng bình thường.
============================================================ */
first_observed_play AS (
SELECT
user_pseudo_id,
logical_level
AS first_observed_level
FROM play_events
QUALIFY
ROW_NUMBER() OVER (
PARTITION BY
user_pseudo_id
ORDER BY
event_timestamp,
COALESCE(event_bundle_sequence_id, 0),
COALESCE(batch_event_index, 0)
) = 1
),
/* ============================================================
7. MAX LEVEL TRƯỚC MỖI LƯỢT CHƠI
============================================================ */
play_with_previous_max AS (
SELECT
p.*,
MAX(
logical_level
) OVER (
PARTITION BY
user_pseudo_id
ORDER BY
event_timestamp,
COALESCE(event_bundle_sequence_id, 0),
COALESCE(batch_event_index, 0)
ROWS BETWEEN
UNBOUNDED PRECEDING
AND 1 PRECEDING
) AS previous_max_level
FROM play_events p
),
/* ============================================================
8. FRONTIER PLAY
GIỮ: new max + retry tại current max.
LOẠI: backward replay (300 -> replay 123).
LEFT CENSOR: first observed level > 1 => bỏ chính level đó.
============================================================ */
frontier_play AS (
SELECT
p.*
FROM play_with_previous_max p
JOIN first_observed_play f
ON f.user_pseudo_id
= p.user_pseudo_id
WHERE
(
p.previous_max_level IS NULL
OR
p.logical_level
>= p.previous_max_level
)
AND NOT (
f.first_observed_level > 1
AND
p.logical_level
= f.first_observed_level
)
),
/* ============================================================
9. OUTCOME OWNER — mapping CUỐI CÙNG user chơi tại frontier X
============================================================ */
level_owner AS (
SELECT
user_pseudo_id,
logical_level,
version,
mapped_level,
country,
media_source,
first_open_cohort,
event_timestamp
AS owner_ts,
/* Mang theo tie-breaker để LEAD phía sau có thứ tự xác định
khi 2 owner trùng event_timestamp. */
COALESCE(event_bundle_sequence_id, 0)
AS owner_seq,
COALESCE(batch_event_index, 0)
AS owner_idx
FROM frontier_play
QUALIFY
ROW_NUMBER() OVER (
PARTITION BY
user_pseudo_id,
logical_level
ORDER BY
event_timestamp DESC,
COALESCE(
event_bundle_sequence_id,
0
) DESC,
COALESCE(
batch_event_index,
0
) DESC
) = 1
),
/* ============================================================
10. NEXT FRONTIER LEVEL — có thể nằm ở version khác
============================================================ */
owner_with_next AS (
SELECT
*,
LEAD(
logical_level
) OVER (
PARTITION BY
user_pseudo_id
ORDER BY
owner_ts,
owner_seq,
owner_idx
) AS next_logical_level,
LEAD(
version
) OVER (
PARTITION BY
user_pseudo_id
ORDER BY
owner_ts,
owner_seq,
owner_idx
) AS next_version,
LEAD(
owner_ts
) OVER (
PARTITION BY
user_pseudo_id
ORDER BY
owner_ts,
owner_seq,
owner_idx
) AS next_level_ts
FROM level_owner
),
/* ============================================================
12. CLASSIFY OUTCOME
============================================================ */
classified AS (
SELECT
o.user_pseudo_id,
o.version,
o.logical_level,
o.mapped_level,
o.country,
o.media_source,
o.first_open_cohort,
o.owner_ts,
o.next_logical_level,
o.next_version,
o.next_level_ts,
ula.last_activity_date,
CASE
WHEN o.next_logical_level > o.logical_level
THEN 1
ELSE 0
END AS is_passed,
CASE
WHEN o.next_logical_level > o.logical_level
AND o.next_version != o.version
THEN 1
ELSE 0
END AS is_cross_version_pass,
CASE
WHEN o.next_logical_level IS NULL
AND ula.last_activity_date
<= DATE_SUB(
p.obs_end,
INTERVAL p.inactive_days DAY
)
THEN 1
ELSE 0
END AS is_confirmed_drop,
CASE
WHEN o.next_logical_level IS NULL
AND ula.last_activity_date
> DATE_SUB(
p.obs_end,
INTERVAL p.inactive_days DAY
)
THEN 1
ELSE 0
END AS is_pending
FROM owner_with_next o
JOIN user_last_activity ula
ON ula.user_pseudo_id
= o.user_pseudo_id
JOIN version_scope vs
ON vs.version = o.version
CROSS JOIN params p
WHERE
o.logical_level
BETWEEN 1 AND p.max_report_level
),
/* ============================================================
13. OUTCOME SUMMARY
============================================================ */
outcome_summary AS (
SELECT
version,
logical_level,
mapped_level,
country,
media_source,
first_open_cohort,
COUNT(*)
AS outcome_users_mapping,
COUNTIF(
is_passed = 1
) AS passed_users_mapping,
COUNTIF(
is_confirmed_drop = 1
) AS confirmed_drop_users_mapping,
COUNTIF(
is_pending = 1
) AS pending_users_mapping,
COUNTIF(
is_passed = 1
OR
is_confirmed_drop = 1
) AS resolved_users_mapping,
COUNTIF(
is_cross_version_pass = 1
) AS cross_version_pass_users
FROM classified
GROUP BY
version,
logical_level,
mapped_level,
country,
media_source,
first_open_cohort
),
/* ============================================================
14. MERGE EXPOSURE + OUTCOME + PHỄU
============================================================ */
metrics_base AS (
SELECT
mp.version,
mp.logical_level,
mp.mapped_level,
mp.country,
mp.media_source,
mp.first_open_cohort,
lp.unique_users_logical_level,
mp.mapping_users,
SAFE_DIVIDE(
mp.mapping_users,
lp.unique_users_logical_level
) AS mapping_share,
mc.mapping_count_at_level,
mp.play_events_mapping,
COALESCE(os.outcome_users_mapping, 0)
AS outcome_users_mapping,
COALESCE(os.passed_users_mapping, 0)
AS passed_users_mapping,
COALESCE(os.confirmed_drop_users_mapping, 0)
AS confirmed_drop_users_mapping,
COALESCE(os.pending_users_mapping, 0)
AS pending_users_mapping,
COALESCE(os.resolved_users_mapping, 0)
AS resolved_users_mapping,
COALESCE(os.cross_version_pass_users, 0)
AS cross_version_pass_users,
COALESCE(fs.level_completed_users, 0)
AS level_completed_users,
COALESCE(fs.level_continued_users, 0)
AS level_continued_users,
COALESCE(fs.drop_during_level_users, 0)
AS drop_during_level_users,
COALESCE(fs.drop_after_level_users, 0)
AS drop_after_level_users,
COALESCE(fs.drop_pending_level_users, 0)
AS drop_pending_level_users
FROM mapping_population mp
JOIN logical_population lp
ON lp.version = mp.version
AND lp.logical_level = mp.logical_level
AND lp.country = mp.country
AND lp.media_source = mp.media_source
AND lp.first_open_cohort = mp.first_open_cohort
/* mapping_count không tách segment — nhiều dòng mp : 1 dòng mc, cố ý */
JOIN mapping_count mc
ON mc.version = mp.version
AND mc.logical_level = mp.logical_level
LEFT JOIN outcome_summary os
ON os.version = mp.version
AND os.logical_level = mp.logical_level
AND os.mapped_level = mp.mapped_level
AND os.country = mp.country
AND os.media_source = mp.media_source
AND os.first_open_cohort = mp.first_open_cohort
/* Cùng grain với mp — 1-1 (funnel_users = mapping_users) */
LEFT JOIN funnel_summary fs
ON fs.version = mp.version
AND fs.logical_level = mp.logical_level
AND fs.mapped_level = mp.mapped_level
AND fs.country = mp.country
AND fs.media_source = mp.media_source
AND fs.first_open_cohort = mp.first_open_cohort
),
/* ============================================================
15. CALCULATED METRICS
============================================================ */
metrics AS (
SELECT
*,
SAFE_DIVIDE(
confirmed_drop_users_mapping,
resolved_users_mapping
) AS drop_rate_mapping,
SAFE_DIVIDE(
pending_users_mapping,
outcome_users_mapping
) AS pending_share,
SAFE_DIVIDE(
outcome_users_mapping,
mapping_users
) AS outcome_coverage,
SAFE_DIVIDE(
cross_version_pass_users,
passed_users_mapping
) AS cross_version_pass_share
FROM metrics_base
),
/* ============================================================
16. SAMPLE STATUS
============================================================ */
quality AS (
SELECT
m.*,
CASE
WHEN mapped_level = 'UNKNOWN'
THEN 'UNKNOWN_MAPPING'
WHEN mapping_users < p.min_mapping_users
THEN 'LOW_SAMPLE'
WHEN resolved_users_mapping < p.min_resolved_users
THEN 'LOW_SAMPLE'
WHEN COALESCE(outcome_coverage, 0) < p.min_outcome_coverage
THEN 'LOW_COVERAGE'
WHEN COALESCE(pending_share, 0) > p.max_pending_share
THEN 'HIGH_PENDING'
ELSE 'OK'
END AS sample_status
FROM metrics m
CROSS JOIN params p
)
/* ============================================================
FINAL OUTPUT
============================================================ */
SELECT
q.version,
q.logical_level,
q.mapped_level
AS mapped_level_thuc_te,
q.country,
q.media_source,
q.first_open_cohort,
/* EXPOSURE */
q.unique_users_logical_level,
q.mapping_users,
ROUND(q.mapping_share * 100, 2)
AS mapping_share_pct,
q.mapping_count_at_level,
/* OUTCOME (Pass/Drop/Pending) */
q.confirmed_drop_users_mapping,
q.resolved_users_mapping,
ROUND(q.drop_rate_mapping * 100, 2)
AS drop_rate_mapping_pct,
ROUND(q.pending_share * 100, 2)
AS pending_share_pct,
ROUND(q.outcome_coverage * 100, 2)
AS outcome_coverage_pct,
/* PHỄU DURING / AFTER (theo từng user) */
q.level_completed_users,
q.level_continued_users,
q.drop_during_level_users,
q.drop_after_level_users,
q.drop_pending_level_users,
q.sample_status,
/* CỬA SỔ ĐÃ DÙNG — để dashboard hiển thị đúng khoảng dữ liệu */
p.start_date
AS cohort_start_date,
p.obs_end
AS cohort_end_date,
p.obs_start
AS activity_start_date,
p.obs_end
AS activity_end_date,
p.data_end_date,
p.cohort_bucket_days
FROM quality q
CROSS JOIN params p
ORDER BY
q.logical_level,
q.version,
q.mapped_level;
Đo % Drop theo từng level, phễu Drop during / Drop after, Retention theo cohort — so sánh giữa các phiên bản app, lọc theo nguồn user, quốc gia, cohort. Dữ liệu gốc lấy từ query Drop Level Data (BigQuery) — mọi định nghĩa Pass/Drop/Pending bên dưới đều là logic THẬT đang chạy trong query đó, không phải suy diễn.
Quy ước: thứ gì hiện trên tab Dashboard đều được định nghĩa ở tab này; tên cột / công thức của query nằm ở tab Data (luôn khớp lệnh query ở mục E).
| Tên | Nghĩa |
|---|---|
| Tổng user (All user) / Tổng user theo Cohort (New user) | Số user đã chơi level thấp nhất trong "Khoảng Level" (mốc 100%), cộng mọi version đang bật. New user = chỉ nhóm cài app trong A→B. |
| Drop trung bình | Σ Confirmed Drop ÷ Σ user đã có kết quả (Pass + Drop) của các map đại diện đủ ngưỡng mẫu. Không lấy trung bình các %. |
| Số level vượt ngưỡng | Số level (trong Khoảng Level) có map đại diện Drop % > Ngưỡng "rủi ro cao" ở ít nhất 1 version đang bật — 1 level vượt ở nhiều version vẫn đếm 1. |
| Cột | Nghĩa |
|---|---|
| Level | Level user nhìn thấy (logical level). ▸ = level có nhiều map, bấm để mở rộng. ⚠ = map đại diện khác level_map (rê chuột: map muốn phát và map game phát thật, kèm % user). |
| Version | Phiên bản app. |
| Level ID | Map user thật sự chơi (source_3). Dòng chính = map đại diện = map đông user nhất trong các map đủ ngưỡng mẫu. Tag "+N map khác" = số map còn lại (bấm dòng để xem). |
| Users | Số user (distinct) đã chơi đúng map đó, theo bộ lọc đang chọn. |
| Retention % | Chỉ có ở New user: user của level này ÷ user của level mốc 100% (cả tử lẫn mẫu là user của cả level, cộng các version đang bật — có thể > 100% ở vài level, xem mục 05 ④). |
| Share % | User của map ÷ user của cả level (cùng version, cùng bộ lọc). |
| Drop during % / Drop after % | % user của map bỏ game khi chưa hoàn thành level (during) / sau khi đã hoàn thành nhưng không lên level sau (after), đã im lặng ≥ 2 ngày. Rê chuột xem số user gốc. Màu theo Ngưỡng "rủi ro cao" T: đỏ > T, vàng > T/2, xanh còn lại. Định nghĩa chi tiết ở mục 03. |
| Nhãn | Nghĩa |
|---|---|
| Low sample | Map dưới Ngưỡng mẫu tối thiểu đang đặt trên Dashboard (mặc định 100 user, hoặc % user của level), hoặc chưa có user nào ra kết quả Pass/Drop. Vẫn hiện để thấy quy mô nhưng không vào KPI, chart, dòng đại diện. Rê chuột xem lý do. |
| Map lẻ (gộp) | Một dòng gộp mọi map chiếm dưới 1% user của (version, level) — do query gộp (OTHER_MAPS, tham số min_map_share) để file nhỏ lại; tổng user vẫn đúng. Không bao giờ làm map đại diện.⚠ Không cùng tiêu chí với Low sample: gộp theo tỷ lệ (< 1%), Low sample theo số user (< ngưỡng). Đo trên data 28/09: 99,9% map bị gộp có dưới 100 user, nhưng ~890 map có ≥ 100 user vẫn bị gộp vì nằm ở level rất đông (vd 1.7.1 L7 map 20007: 1.106 user = 0,56% level) — các map này không xem riêng được. Ngược lại, map ≥ 1% vẫn có thể mang nhãn Low sample nếu ít user. |
| map mong muốn | Map này đúng Level ID trong level_map (tab Data) của version đó. |
| Không rõ map | Event thiếu tham số source_3 nên không biết map (UNKNOWN). Drop / Retention / during / after vẫn đúng. |
| Giá trị | Nghĩa |
|---|---|
| Country OTHER | Mọi nước ngoài top 10 nước đông user nhất (country_top_n). Việt Nam bị loại hẳn khỏi data. |
| Nguồn user unknown | User chưa nhận được attribution (không có media_source lẫn event ua_source_*). |
| Cohort New user + A→B | User cài app (first_open) trong A→B. Chọn theo kỳ cài 7 ngày (khoảng thực tế ghi dưới ô ngày), đổi là cập nhật ngay; không chọn được trước 19/06/2026 (ngày user đầu tiên chơi 1.6.4) hay sau ngày cuối data. |
| Version bị ẩn | Version tổng dưới 100 user (bản build lẻ) — tên ghi dưới dãy chip. Bản 1.0 → 1.5 không có trong data (chưa có event gd_play_level). |
| Thứ | Nghĩa |
|---|---|
| Chart | Mỗi đường = 1 version, điểm = Drop % (Confirmed Drop ÷ đã có kết quả) của map đại diện từng level. Đường ngang = Ngưỡng "rủi ro cao". Rê chuột xem đúng level đó. |
| Dòng "Data đang xem" | File đang nạp và khoảng ngày của nó (data mặc định hoặc file bạn nạp ở tab Data). |
| Băng cảnh báo vàng | Chỉ hiện khi nạp file query CŨ (nhãn clean/censored) mà đổi A→B — file cũ có New user cố định nên số không đổi theo; chạy query mới để chọn A→B tức thì. |
| Bộ lọc | Lọc theo | Trạng thái | Ghi chú |
|---|---|---|---|
| Version | Chọn 1 hoặc nhiều phiên bản để so sánh cạnh nhau | Đã hoạt động | Màu cố định theo version xuyên suốt, không đổi khi bật/tắt filter khác. Bản 1.0 → 1.5 không có trong data (chưa có event gd_play_level). Version có tổng dưới 100 user (bản build lẻ) tự ẩn — ghi tên dưới dãy chip, gõ lại vào ô "+ Thêm version" để hiện |
| Khoảng Level | Từ level X đến Y, tự do — mặc định 1 → 1000 | Đã hoạt động | Query mới xuất tới L1000 (max_report_level) — bản hiện tại chưa chạm vùng Loop, nhưng 1.6.4 / 1.7.1 / 1.8.13 thì có (mục 04). Đồng thời là mốc 100%: level thấp nhất có data trong khoảng → dùng cho KPI "Tổng user" và Retention %. Đổi khoảng là đổi mốc (vd soi riêng L200→250 thì L200 = 100%). |
| Nguồn user (media source) | Danh sách nguồn thật trong data (organic, moloco, …, unknown) |
THẬT (data mặc định 28/09) | Data mặc định (query 28/09) có cột này → số THẬT; chỉ khi trình duyệt không nạp được data mặc định thì mới rơi về data mẫu cũ (filter chạy MOCK, có cờ ⚠). Mỗi user 1 giá trị: user property media_source → event ua_source_* → unknown. Chi tiết ở tab Data. |
| Ngưỡng mẫu tối thiểu | Số user tối thiểu của 1 map, hoặc % user của level (chọn đơn vị; nhận số thập phân, vd 0.1%) | Đã hoạt động | Gõ tới đâu Dashboard cập nhật tới đó. Map dưới ngưỡng không bị xoá: vẫn hiện khi mở rộng level, gắn nhãn Low sample (rê chuột xem lý do + sample_status của SQL), nhưng không vào KPI, chart, dòng đại diện. Map chưa có user nào ra kết quả Pass/Drop luôn tính là dưới ngưỡng. Level không có map nào đủ ngưỡng thì ẩn — số level bị ẩn ghi ở chân bảng. Mặc định 100 user. |
| Ngưỡng "rủi ro cao" | Mốc % Drop nào bị coi là đáng lo | Đã hoạt động | Mặc định 3%, chỉnh tự do theo giai đoạn sản phẩm (nhận cả 0). Cũng là mốc tô màu cột Drop during % / Drop after %: đỏ > ngưỡng, vàng > ½ ngưỡng, xanh còn lại |
| 3 KPI tổng quan | Tổng user (theo Cohort nếu chọn New user, không thì All user) · Drop trung bình · Số level vượt ngưỡng | Đã hoạt động | Tổng user = user đã chơi level mốc 100% (level thấp nhất trong Khoảng Level), gộp mọi version đang bật. Drop TB = Σ Confirmed Drop ÷ Σ đã có kết quả của các map đại diện. Số level vượt ngưỡng = số level có map đại diện Drop % > ngưỡng ở ít nhất 1 version đang bật (1 level vượt ở nhiều version vẫn đếm 1). Drop TB và Số level vượt ngưỡng chỉ tính map đủ ngưỡng mẫu; Tổng user thì tính MỌI user của level mốc 100% (không qua ngưỡng map). Data cũ/mock thiếu cột confirmed/resolved thì Drop TB rơi về trung bình theo số user của map — 2 cách có thể lệch nhau khi tỷ lệ đã-có-kết-quả giữa các map không đều (chân KPI ghi đang dùng cách nào). |
| Cohort cài đặt (first_open) + ô A→B | Tất cả (All user): không lọc ngày cài — mọi user cũ + mới. New user: chọn A→B = khoảng cài app (first_open), trong khoảng data 19/06 → data_end. |
THẬT (query mới) | Đổi A→B cập nhật số NGAY, không cần chạy lại query: query ghi mỗi user mới 1 kỳ cài (mặc định 7 ngày), Dashboard cộng các kỳ trong A→B (làm tròn theo kỳ, ghi rõ khoảng thực tế dưới ô ngày). Không chọn được ngày trước 19/06 (ngày user đầu tiên chơi 1.6.4) hay sau data_end. Không còn C→D: luôn lấy toàn bộ hoạt động của khoảng data. Chỉ New user mới có Retention %. File query cũ (clean/censored) thì A→B cố định — có băng cảnh báo. |
| Country | Dropdown đa chọn — liệt kê hết quốc gia có trong data, mỗi nước 1 checkbox bật/tắt riêng. Mặc định BẬT hết TRỪ Việt Nam (query mới đã loại VN từ gốc; mặc định này để file cũ vẫn đúng) | THẬT (data mặc định 28/09) | Mỗi user gán đúng 1 nước (nước xuất hiện nhiều nhất trong event của họ) → bật nhiều nước không đếm trùng người. Query chỉ giữ tên 10 nước đông user nhất, còn lại là OTHER. Danh sách xếp theo số user giảm dần, OTHER luôn cuối; khung đủ cao để thấy ≥ 12 dòng không cần cuộn |
| Mở rộng 1 level | Bấm dòng có dấu ▸ để xem mọi map còn lại của level đó | Đã hoạt động | Chỉ ảnh hưởng bảng kết quả, không đổi số ở KPI/chart. Dòng chính = map đại diện (xem mục 04); dòng con xếp giảm dần theo số user, gồm cả map dưới ngưỡng (nhãn Low sample) và dòng "Map lẻ (gộp)" (map < 1% user của level, query gộp sẵn) |
| Level ID mong muốn | level_map theo từng version 1.2.2 → 1.11 (GD cung cấp 28/09), nạp sẵn ở tab Data, sửa được | Đã hoạt động | Chưa phải filter — là danh sách đối chiếu (map muốn phát): rê chuột vào ⚠ thấy cả map muốn phát lẫn map game phát thật, kèm % user; ⚠ ở cột Level khi map đại diện (đông user nhất trong các map đủ ngưỡng mẫu) khác level_map của đúng version đó; khi mở rộng level, map đúng level_map có nhãn "map mong muốn". Level không khai = chính số level; khai trùng → lấy giá trị sau. Version không có trong danh sách (1.12+) thì không cảnh báo. Xoá trắng ô rồi bấm Áp dụng = tắt cảnh báo |
| Khoảng thời gian (time range) | So Drop theo tuần/tháng thay vì gộp 1 cục | MỘT PHẦN | So theo tuần cài app đã làm được ngay trên Dashboard (chọn A→B theo kỳ 7 ngày). So theo tuần hoạt động (cùng nhóm user, khác khoảng ngày chơi) chưa làm: kết quả Drop/Pending phụ thuộc cả khoảng ngày nên không cộng dồn được — cần đổi cấu trúc query (đã bỏ C→D theo quyết định). |
Đọc thẳng code Unity (r23-multiple-surface), không suy đoán — trạng thái CONFIRMED cho cả 3.
gd_play_levelNền tảng của toàn bộ Pass/DropBắn khi nào: ngay khi màn chơi BẮT ĐẦU tải — đồng bộ, trong Start() của GameplayManager, TRƯỚC KHI chắc chắn map nào thật sự được load xong (đây chính là gốc của bug lệch source_3 đã note ở phần "Loop / retry" tab Dashboard).
Tham số chính: level (logical level), source_3 (map thực tế), level_set.
Nguồn: GameplayManager.cs:1067-1079
⚠ Toàn bộ logic PHÂN LOẠI Pass/Drop/Pending (mục 03) CHỈ dựa vào event này — không đụng gd_complete_level, kể cả sau khi thêm Drop during/after. Xem hệ quả ở mục 05.
gd_complete_levelDùng cho Drop during/after — KHÔNG dùng cho Pass/Drop/PendingBắn khi nào: khi popup Thắng màn mở ra (WinPopupController.Init() → FireLevelCompleteEvents()). Có 2 nguồn gọi tới popup này:
GameplayManager.CompleteLevel(), khi đủ điều kiện ở 1 trong 3 chế độ (bag / special-bottle / bottle-sort thường), có guard IsLevelActive chống bắn đôi.game_tool (mặc định bật = true), không tự động tắt riêng cho bản production.Tham số chính: level, source_3.
Nguồn: WinPopupController.cs:248-264, GameplayManager.cs:1230-1245, GameplayTool.cs:290-310
⚠ Nếu remote config game_tool đang bật cho user thường (không riêng QA), event này có thể bắn GIẢ — kiểm tra config trước khi dùng gd_complete_level làm tín hiệu tin cậy tuyệt đối.
count_back_homeTồn tại nhưng chưa dùng cho DropBắn khi nào: user bấm nút Home ở pause menu GIỮA màn — không phải thoát app, không phải nút back OS. Đây là tín hiệu DUY NHẤT trong toàn bộ codebase ghi nhận "chủ động rời màn khi chưa xong".
Tham số chính: is_restore (màn đó có phải đang được khôi phục hay không).
Nguồn: SettingPopupController.cs:173-226
⚠ Không có event nào cho: bấm nút back OS, kill app, chuyển sang app khác giữa màn — những trường hợp đó KHÔNG có tín hiệu riêng, phải suy diễn từ việc im lặng.
Drop Level Data (toàn văn ở tab Data, mục E)Liệt kê hết case đang được query xử lý — copy bảng này ra Sheet để bạn thêm case mới khi phát sinh. Mỗi nhóm xếp case từ bình thường tới phức tạp hơn (thường có update xen vào).
| Case | Vì sao |
|---|---|
| Chơi level X, sau đó chơi được level cao hơn X (cùng version) | Lần chơi kế tiếp của user có logical_level cao hơn |
| Update app, KHÔNG chơi lại X, tiến thẳng lên level cao hơn ở bản mới update | Tiến trình theo dõi xuyên version (partition theo user, không theo version) |
| Update lên 1 bản KHÔNG nằm trong danh sách version đang theo dõi, vẫn tiến tiếp update | Dữ liệu progression không lọc version ở bước theo dõi tiến trình, chỉ lọc ở bước xuất kết quả |
| Lên đúng level cuối của báo cáo (L1000) rồi chơi tiếp L1001 | Query tự theo dõi dư 1 level (max_report_level + 1) để level cuối không bị coi là "ai cũng kẹt" |
| Case | Vì sao |
|---|---|
| Không có level cao hơn sau đó, VÀ lần hoạt động cuối cùng ≥ 2 ngày trước mốc cắt dữ liệu | Đủ thời gian để chắc chắn user không quay lại nữa |
| Case | Vì sao |
|---|---|
| Không có level cao hơn, nhưng lần hoạt động cuối chỉ mới 0-1 ngày trước mốc cắt | Chưa đủ thời gian kết luận — có thể mai quay lại |
Quy tắc chung: mỗi user chỉ có ĐÚNG 1 outcome / level — thuộc về lần chơi CUỐI CÙNG. "Không có outcome" ở đây KHÔNG có nghĩa là mất trắng luôn — cột cuối chỉ rõ outcome thật đang nằm ở dòng/bản nào.
| Case | Vì sao | Bản nào TÍNH / KHÔNG TÍNH |
|---|---|---|
| Thua và retry nhiều lần liên tiếp tại đúng level đang đứng, chưa qua được | Chỉ tính DUY NHẤT 1 outcome — theo lần retry cuối cùng, không cộng dồn nhiều Drop | Các lần retry TRƯỚC → KHÔNG TÍNH. Lần retry CUỐI CÙNG (tính tới mốc cắt) → CÓ TÍNH, đi qua phân loại bình thường (ra Confirmed Drop nếu im lặng ≥2 ngày, Pending nếu 0-1 ngày). |
| Quay lại chơi 1 level thấp hơn mức cao nhất đã đạt (farm/replay) | Level đó coi như đã có kết quả từ trước, lượt chơi lại không đổi outcome | Lần farm/replay này → KHÔNG TÍNH. Outcome thật đã TÍNH TỪ TRƯỚC — đúng lần đầu tiên user vượt qua level đó (đã là Pass). |
| Lần chơi đầu tiên quan sát được trong data đã ở level > 1 (left-censoring) | Không biết đó là tiến trình thật hay đang chơi lại level cũ trước ngày bắt đầu lấy data — dùng filter "Cohort cài đặt (first_open)" = New user ở mục 01 để chỉ xem nhóm cài trong A→B — giảm mạnh rủi ro này khi khoảng hoạt động bắt đầu từ A; không loại hẳn nếu thiếu event đầu kỳ (khoảng data luôn bắt đầu từ 19/06 nên New user thấy được từ lần chơi đầu) | KHÔNG DÒNG NÀO TÍNH — case DUY NHẤT thật sự mất outcome, không có bản nào khác sở hữu nó. |
| Update app, chơi LẠI đúng level X (map đổi khác), rồi nghỉ hẳn update | Outcome chỉ thuộc về lần chơi CUỐI CÙNG tại level đó — bản/map cũ mất quyền, dù vẫn được đếm "đã có người chơi qua" | Bản/map CŨ (trước update) → KHÔNG TÍNH. Bản/map MỚI (sau update, lần chơi cuối) → CÓ TÍNH, đi qua phân loại bình thường (Pass nếu lên tiếp, Confirmed Drop nếu im lặng ≥2 ngày, Pending nếu 0-1 ngày). |
Tách riêng khỏi Pass/Drop/Pending ở trên (không thay thế). Với mỗi user đã chơi 1 dòng (version × level X × map × segment), lấy lần chơi ĐẦU TIÊN của họ ở dòng đó làm mốc, rồi xếp user vào đúng 1 trong 4 nhóm — 4 nhóm không giao nhau, cộng lại = số user của map, nên không bao giờ âm (bản cũ lấy hiệu 2 phép đếm độc lập nên từng ra số âm). Dashboard hiện Drop during % và Drop after % = số user nhóm đó ÷ số user của map (4 nhóm cộng lại = 100%); số user gốc xem ở tooltip.
| Nhóm | Điều kiện (sau mốc) | Ví dụ |
|---|---|---|
| Continued | Có gd_play_level ở level cao hơn X (bất kể version/map) | Chơi L20 ở 1.10, update, chơi L21 ở 1.11 → Continued cho dòng 1.10 L20 |
| Drop after | Có gd_complete_level ở level X (bất kể map/version), KHÔNG lên level cao hơn, và đã im lặng ≥ 2 ngày | Thắng L20 rồi thoát app trước khi L21 kịp tải, không quay lại |
| Drop during | KHÔNG complete X, KHÔNG lên level cao hơn, và đã im lặng ≥ 2 ngày | Thua L20 vài lần rồi bỏ game |
| Pending | KHÔNG lên level cao hơn nhưng vẫn còn hoạt động trong 2 ngày cuối | Đang kẹt ở L20, hôm qua vẫn mở app — chưa kết luận |
Đây không phải nhiễu ngẫu nhiên. Engine quyết định "level X hiện nội dung nào" qua đúng 4 lớp ưu tiên, tra thẳng GameLevelLoader.cs. Hiểu đúng 4 lớp này thì mới biết khi nào là bạn (GD) chủ động đổi nội dung, khi nào là engine tự lặp do hết nội dung độc bản, và khi nào là nhiễu/bug.
Bản hiện tại chạy maxLevel = 1000, startLoopLevel = 41 (tra ScriptHolder.prefab:1303-1304) nên 1.9.2 trở lên chưa chạm vùng lặp trong L1–1000. Nhưng data thật (28/09) cho thấy các build cũ có maxLevel thấp hơn: 1.6.4 = 300, 1.7.1 = 350, 1.8.13 = 500. Qua mức đó, map = (L − 41) mod (maxLevel − 40) + 41 — vd 1.6.4 L301 → map 41, L651 → 131. Công thức này khớp map đông user nhất ở 1.850/1.850 level của 3 version trên. Map lặp không áp level_map (vd 1.6.4 L305 → 45, không phải 1045). Dashboard đã tính sẵn quy tắc này khi so Level ID mong muốn. (INFERRED từ data — maxLevel của các build cũ không còn trong nhánh code hiện tại để tra.)
| Lớp | Tên gọi vận hành | Có trong data L1-1000? | Khi nào áp dụng — nghĩa là gì với người vận hành |
|---|---|---|---|
| 1 | Level thay thế — Remote Config | CÓ | Có 1 bảng override (_levelOverrides) nạp từ config level_set trên server, dạng cặp "level X → nội dung Y". Đây là công cụ để GD chủ động đổi nội dung 1 level cụ thể mà KHÔNG cần build lại app — cứ đổi config, app tự tải bảng mới. Đây là nguyên nhân chính khiến hầu hết level trong data thật có mapped_level ≠ logical_level (vd L1 → map 2001/20001). |
| 2 | Level thay thế — theo từng NGƯỜI | CÓ | 1 số user cũ (đã migrate qua đợt đổi cấu trúc level) có 1 độ lệch riêng lưu ngay trên máy họ (_displayOffset). Mục đích: user cũ vẫn thấy đúng số level quen thuộc, nhưng thực chất được phục vụ nội dung MỚI phía sau. Khác lớp 1 ở chỗ: lớp 1 áp theo LEVEL, lớp 2 áp theo TỪNG NGƯỜI. |
| 3 | Level bị Loop | CÓ ở 1.6.4 (L301+), 1.7.1 (L351+), 1.8.13 (L501+) | Khi số level vượt quá ngưỡng nội dung độc bản (maxLevel của build đó — bản hiện tại 1000, bản cũ 300/350/500), engine tự động lặp vòng lại 1 dải nội dung cũ (startLoopLevel → maxLevel) theo công thức chia dư — càng lên cao, nội dung càng LẶP LẠI có chu kỳ. Hành vi CỐ Ý (chống hết nội dung), không phải bug. |
| 4 | Né obstacle đặc biệt khi Loop | Có thể (cùng vùng với lớp 3) | Trong lúc đang ở vùng Loop, nếu nội dung được chọn trúng loại "Special Bottle", engine tự tìm nội dung KẾ TIẾP trong dải loop để thay. Chỉ có ý nghĩa khi lớp 3 đã kích hoạt. |
| — | Level thật | CÓ | Không dính lớp nào ở trên → mapped_level = logical_level. Nội dung gốc, đúng như số hiển thị — trong data thật đây thường là level còn nhỏ hoặc chưa được GD can thiệp qua remote config. |
Nguồn: GameLevelLoader.cs:727-814 (region "Loop Level — Mapping") — GetMappedLevel(), ResolveContentLevel(), ResolveLoopEligibleLevel()
Vì ≤ 1.11 vẫn còn lớp 1 (Remote Config override) nên 1 logical_level (vd L202) có thể có nhiều mapped_level thật cùng lúc — dashboard xử lý bằng cách chọn 1 map "đại diện" mặc định, cho mở rộng xem hết khi cần:
| Câu hỏi | Cách xem trong dashboard | Cách tính |
|---|---|---|
| "Level 202 nói chung đang khoẻ không?" — nhìn nhanh, không cần soi từng map | Dòng mặc định (thu gọn) | Hiện ĐÚNG map có số user cao nhất trong số map đủ ngưỡng mẫu tại level đó — map đa số người chơi đang thấy, KHÔNG phải blend/trung bình. Chart và KPI cũng chỉ dùng số của map này. |
| "Map nào ở level 202 nên chọn/giữ lại?" — so sánh winner, cần thấy hết map | Bấm vào dòng để mở rộng | Level nào có > 1 map thật sẽ có dấu ▸ ở đầu dòng — bấm vào mở rộng thêm dòng con cho từng map còn lại, xếp giảm dần theo số user (map đông user nhì, ba,...) — hiện hết mọi map còn lại. Map dưới ngưỡng mẫu hiện ở đây với nhãn Low sample; map < 1% user của level nằm chung trong dòng Map lẻ (gộp). |
Level có nhiều map thật sẽ thấy tag "+N map khác" ngay cạnh tên map ở dòng mặc định — đó là dấu hiệu để biết nên bấm mở rộng hay không. Query gộp mọi map chiếm < 1% user của level thành 1 dòng "Map lẻ (gộp)" (OTHER_MAPS, tham số min_map_share) — tính là 1 trong "+N", không bao giờ làm map đại diện.
Vì 1.12 sẽ query riêng L1→L1000 với nội dung đã áp cố định theo đúng winner chọn ra từ phân tích ≤1.11 — mỗi logical_level chỉ còn ĐÚNG 1 mapped_level, không còn đa map do Remote Config override nữa. Dashboard không ép quy tắc này: nếu data 1.12+ thực tế vẫn có nhiều map ở 1 level, dấu ▸ vẫn hiện để mở rộng — đó là tín hiệu cần kiểm tra lại config.
→ Rất có thể là lịch sử đổi config thật — 2 config level_set khác nhau đã từng live ở 2 mốc thời gian khác nhau trong cùng version (đúng case đã thấy ở L402/1.10.4: map 402 và map 1402 cùng đạt sample_status = OK, cùng có sample lớn). Đây là tín hiệu để so sánh 2 lần đổi nội dung, không phải để loại bỏ.
→ Đây là case nhiễu đã note ở tab Dashboard (mục "Loop / retry"). Giờ có thêm 1 manh mối: rất có thể rơi đúng vào user thuộc lớp 2 (display offset — user migrated), bị dính đúng lúc bảng override (lớp 1) đang trống do race condition khi vừa đổi config — nên công thức fallback tính ra nội dung dựa trên số level đã bị lệch offset, ra số "không liên quan" tới cả level thật lẫn nội dung engine sắp load thật. (INFERRED — chưa lần theo được từng user cụ thể để xác nhận 100%, nhưng khớp với mọi bằng chứng đã có.)
Phân loại Pass/Drop/Pending (mục 03) vẫn KHÔNG đọc gd_complete_level — Pass chỉ được suy ra khi thấy gd_play_level của level kế tiếp. Nếu user thắng rồi thoát app ngay (trước khi màn sau kịp load), họ sẽ bị tính SAI thành Drop hoặc Pending dù thực chất đã thắng. Phễu Drop during/after (mục 03) đọc thẳng gd_complete_level nên user này rơi đúng vào Drop after, không bị nhầm thành Drop during — nhưng phễu lại dính rủi ro ② bên dưới.
Nếu remote config game_tool đang bật cho user thường (không riêng QA/internal), nút "Win" trong GameplayTool tạo Pass giả 100% giống thật, không có cách phân biệt trong data hiện tại. Rủi ro này lan sang phễu Drop during/after: user bấm Win giả bị tính là "đã complete" → chuyển từ Drop during sang Drop after, khiến Drop during bị đánh giá THẤP hơn thực tế nếu config đang bật rộng.
user_pseudo_id đổi hoàn toàn → máy cũ bị tính Drop oan, máy mới tính như user mới tinh. Không có identity resolution nào xử lý việc này.
Retention % cộng user của mọi version đang bật. 1 user chơi cùng level ở 2 version (update giữa chừng) được đếm 2 lần ở level đó → Retention có thể nhỉnh lên, thậm chí > 100% ở vài level. Dashboard hiện đúng số (không cắt 100%) để lộ ra thay vì che. Muốn sạch: chỉ bật 1 version. Đã chốt tạm: giữ cách gộp này; tách theo từng version để sau.
User cũ vào khoảng data khi đã ở level cao hơn level mốc (vd đang ở L300) không nằm trong số này. Với New user thì không có vấn đề (ai cũng bắt đầu từ đầu).
Ngưỡng mẫu của Dashboard chỉ xét số user của map (hoặc % level), không xét số user đã có kết quả Pass/Drop — map đông user nhưng phần lớn còn Pending vẫn có thể lọt ngưỡng. Đã chốt tạm: chưa thêm điều kiện này. (Nhóm media unknown = user chưa nhận được attribution — game chỉ có Android.)
Mọi version × L1000 × mọi nước × mọi nguồn có thể ra rất nhiều dòng (file vài chục MB trở lên). File query cũ (3 version × L500, tách 210 nước) đã là 372.251 dòng × 28 cột ≈ 10,4 triệu ô, 38,7 MB. Đã chốt: chỉ giữ tên 10 nước đông user nhất, còn lại gộp OTHER (country_top_n = 10) — trên file cũ giảm 372k → ~115k dòng, 10 nước này chiếm ~65% user. Nếu vẫn không tải hết: giảm country_top_n, đặt split_by_country/split_by_media = FALSE hoặc giới hạn report_versions. File thật 28/09 (bản top 10 nước) ra 3,0 triệu dòng / 426 MB — ~78% số dòng là map chiếm < 1% user của level, nên query gộp chúng thành OTHER_MAPS (min_map_share = 0.01): chạy thật 28/09 (bq-results-20260928-071719) còn 879.725 dòng / 127 MB, số user từng level khớp tuyệt đối lần chạy trước. Sau khi thêm kỳ cài app 7 ngày (để chọn A→B không chạy lại) và lấy data từ 19/06: 3.246.128 dòng / 480 MB (bq-results-20260928-075031). Query xuất 27 cột mà Dashboard dùng (đã bỏ 9 cột trung gian: lượt chơi, đếm passed/pending/outcome, cross-version pass, xếp hạng map, số ngày nghỉ).
events_intraday_*
Dataset hiện tại chỉ thấy bảng events_intraday_* (bảng daily events_* không có — nghi export daily đang tắt), nên query đọc intraday. Trước khi tin số của 1 khoảng ngày dài: kiểm tra đủ bảng cho từng ngày trong khoảng data (19/06/2026 → data_end). Nếu sau này bật lại export daily thì đổi tên bảng trong query sang events_*.
Hiện tại user bấm Home giữa màn vẫn rơi vào đúng nhánh "im lặng → chờ 2 ngày → Confirmed Drop" giống hệt user thoát hẳn app/mất tích — dù đây là 2 hành vi rất khác nhau (chủ động bỏ ngay vì khó/bực, so với chỉ đơn giản chưa quay lại). Nối count_back_home vào query có thể tách 2 nhóm này ra, giúp đọc đúng hơn "user rớt vì khó" vs "user rớt vì quên/bận".