2.1 榜单结构与品类分布

榜单不是一张排名表,而是一组可切分的结构切片。本篇给一套只依赖公开可观察机制与示例数据的榜单结构分析方法:把榜单聚合成品类分布,用 Top-N 占比与 HHI 衡量集中度,产出可重跑的 JSON、CSV 与纯 Python SVG 条形图与饼图,全程不碰任何真实平台后台数据。

上一章把市场拆成了平台、分发与规模三块,这一章换一个更贴近产品的视角:榜单。榜单是绝大多数小游戏团队唯一能低成本拿到的公开市场信号,但它极易被误读成一张「谁第几名」的排名表。本篇要做的,是把榜单当成一组可切分的结构来读。需要先说清分工:/h5游戏产业全景/ 那篇是 H5 小游戏产业的宏观综述(格局、代表产品、趋势叙事),而本章给的是可量化的榜单结构分析——把榜单变成品类份额与集中度指标,结论可以重跑、可以换数据复算。另一个容易混淆的是 /posts/products/ 下的 PRD 系列:那是产品需求文档的写法与样例,本章给的是可跑的数据分析与机会评估脚本,两者一个产出文档、一个产出可执行的量化结论,不要互相替代。

榜单不是排名表,是结构切片

一张「Top 30」榜单里,真正对产品设计有约束力的不是名次,而是三件可计算的事:

  1. 品类分布:这 30 款里,消除占几款、合成占几款?份额集中在哪几个品类?
  2. 集中度:头部是不是吃掉了一大半?头部越集中,新进入者的容错空间越小。
  3. 结构变化:某个品类的份额在涨还是在跌?(需要两期数据,本篇先解决单期切分。)

名次会变、榜单会换,但「这个品类在榜上占多大份额、市场是分散还是集中」这类结构量,跨平台、跨时点都有可比性。这才是我们做产品决策要的输入。

先说一条纪律:真实榜单数据不可得。任何平台的榜单后台、下载量、流水,都属于「需真实业务环境,命令行不可复现」(X)。所以本节所有数字都来自作者构造的示例榜单,并在每一处显式标注「示例数据,非真实榜单」。我们演示的是算法,不是某天的真实排名——换成你自己抄下来的榜单,同一套脚本立刻给出你那份数据的结论。

三类榜单与它们各自能回答什么

在动手之前,先分清榜单的几种常见形态,它们回答的问题不一样:

榜单类型排序依据(公开可观察)能回答的问题不能回答
下载/新增榜单位时间新增安装谁在拉新、哪些品类好买量留存、付费、真实流水
畅销/收入榜平台口径的流水或流水估算谁的商业化强、头部结构具体数值(后台才有)
热度/人气榜平台自定义的综合热度大盘注意力分布任何绝对量
口碑/评分榜用户评分与评论量质量与体验分布商业表现

关键认知:这三类榜单的品类分布往往不一样。下载榜可能被某个低价买量品类霸榜,畅销榜可能被重氪品类霸榜,热度榜则是两者的加权。做产品时,你要先想清楚自己看的是哪张榜——因为它决定了你看到的「机会」到底是不是真的机会。

下面这段是 D 层配置:一份示例榜单,30 款虚构游戏,每款标一个无量纲的「热度指数」heat,用来演示后面的品类聚合与集中度算法。

{
  "version": 1,
  "observation": "示例数据(非真实榜单)",
  "metric": "heat",
  "metricNote": "heat 为无量纲热度指数,仅用于演示集中度与品类分布算法,不代表任何真实平台的下载、流水或活跃数据。",
  "disclaimer": "本表是作者构造的示例榜单,用于演示榜单结构分析的算法,不是任何真实平台的榜单快照。真实榜单数据需真实平台后台,命令行不可复现(X)。",
  "genres": [
    { "id": "match3", "name": "消除" },
    { "id": "puzzle", "name": "益智解谜" },
    { "id": "merge", "name": "合成" },
    { "id": "idle", "name": "放置" },
    { "id": "sim", "name": "模拟经营" },
    { "id": "casualpvp", "name": "休闲竞技" },
    { "id": "rhythm", "name": "音乐节奏" },
    { "id": "towerdefense", "name": "塔防" },
    { "id": "runner", "name": "跑酷" },
    { "id": "action", "name": "动作" }
  ],
  "games": [
    { "rank": 1, "name": "星际方块", "genre": "puzzle", "heat": 9800 },
    { "rank": 2, "name": "甜甜消消乐", "genre": "match3", "heat": 7600 },
    { "rank": 3, "name": "放置奇兵", "genre": "idle", "heat": 6900 },
    { "rank": 4, "name": "果冻合成", "genre": "merge", "heat": 6200 },
    { "rank": 5, "name": "岛屿经营家", "genre": "sim", "heat": 5400 },
    { "rank": 6, "name": "节奏光点", "genre": "rhythm", "heat": 4800 },
    { "rank": 7, "name": "弹幕大乱斗", "genre": "casualpvp", "heat": 4300 },
    { "rank": 8, "name": "守卫塔防塔", "genre": "towerdefense", "heat": 3900 },
    { "rank": 9, "name": "疾风跑酷", "genre": "runner", "heat": 3500 },
    { "rank": 10, "name": "密室谜题", "genre": "puzzle", "heat": 3200 },
    { "rank": 11, "name": "萌宠消消", "genre": "match3", "heat": 2900 },
    { "rank": 12, "name": "合成大西瓜", "genre": "merge", "heat": 2600 },
    { "rank": 13, "name": "我的咖啡店", "genre": "sim", "heat": 2300 },
    { "rank": 14, "name": "飞刀大师", "genre": "casualpvp", "heat": 2100 },
    { "rank": 15, "name": "无尽放置", "genre": "idle", "heat": 1900 },
    { "rank": 16, "name": "泡泡消除", "genre": "match3", "heat": 1700 },
    { "rank": 17, "name": "数字华容道", "genre": "puzzle", "heat": 1500 },
    { "rank": 18, "name": "动物园消消", "genre": "match3", "heat": 1300 },
    { "rank": 19, "name": "机甲守卫", "genre": "towerdefense", "heat": 1150 },
    { "rank": 20, "name": "天天跑酷", "genre": "runner", "heat": 1000 },
    { "rank": 21, "name": "音乐钢琴块", "genre": "rhythm", "heat": 900 },
    { "rank": 22, "name": "合成乐园", "genre": "merge", "heat": 800 },
    { "rank": 23, "name": "模拟城市小镇", "genre": "sim", "heat": 700 },
    { "rank": 24, "name": "切水果王者", "genre": "casualpvp", "heat": 620 },
    { "rank": 25, "name": "挂机英雄", "genre": "idle", "heat": 540 },
    { "rank": 26, "name": "连连看经典", "genre": "puzzle", "heat": 470 },
    { "rank": 27, "name": "糖果消消", "genre": "match3", "heat": 400 },
    { "rank": 28, "name": "合并宠物", "genre": "merge", "heat": 340 },
    { "rank": 29, "name": "火柴人动作", "genre": "action", "heat": 290 },
    { "rank": 30, "name": "星空消除", "genre": "match3", "heat": 240 }
  ],
  "probe": {
    "topN": 10,
    "hhiScale": 10000
  }
}

这份配置故意做成纯数据:没有一条数字声称来自真实平台,disclaimer 字段把「示例」写死在文件里。你要换成自己抄的榜单,只需替换 games 数组,算法一字不改。

品类分布:从份额到集中度(S 层)

有了榜,第一件事是把它聚合成品类分布。下面这段是 S 层仿真脚本,读 config/ch02_rank.json,算每个品类的份额(万分比整数)、Top-N 占比与 HHI,产出 JSON + CSV + 两个纯 Python SVG(条形图与饼图)。注意份额一律用万分比整数表示、除法一律整数地板除,与全书的跨语言口径一致。

# file: sim/ch02_rank_scan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch02_rank_scan.py --config config/ --out out/
"""榜单结构分析(示例数据口径):读 config/ch02_rank.json,把示例榜单聚合成品类分布,
算集中度指标(Top-N 占比、HHI),产出 JSON + CSV + 两个纯 Python SVG(条形图 + 饼图)。

纪律:输入是作者构造的示例榜单,不是真实平台榜单。真实榜单需真实平台后台,命令行不可复现(X)。
所有份额用万分比整数(bp)表示,除法一律整数地板除,配置与中间量不含浮点。
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import os
import xml.etree.ElementTree as ET


def load_config(path: str) -> dict:
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def genre_share(games: list[dict], genres: list[dict], total: int) -> list[dict]:
    """按品类聚合 heat,份额用万分比整数表示(heat * 10000 // total)。"""
    name_of = {g["id"]: g["name"] for g in genres}
    agg: dict[str, int] = {}
    for g in games:
        agg[g["genre"]] = agg.get(g["genre"], 0) + g["heat"]
    rows = []
    for gid, heat in agg.items():
        rows.append({"genre": gid, "name": name_of.get(gid, gid), "heat": heat,
                     "shareBp": heat * 10000 // total})
    rows.sort(key=lambda r: (-r["heat"], r["genre"]))
    return rows


def concentration(games: list[dict], total: int, top_n: int, hhi_scale: int) -> dict:
    """Top-N 占比(万分比)与 HHI(0..hhi_scale,越大越集中)。"""
    heats = sorted((g["heat"] for g in games), reverse=True)
    top = sum(heats[:top_n])
    bp = [h * 10000 // total for h in heats]
    hhi = sum(x * x for x in bp) // 10000
    return {
        "nGames": len(games),
        "totalHeat": total,
        "topN": top_n,
        "topNShareBp": top * 10000 // total,
        "hhi": hhi,
        "hhiScale": hhi_scale,
        "hhiLevel": "高集中" if hhi >= 2500 else ("中等集中" if hhi >= 1500 else "较分散"),
    }


def write_bar_svg(path: str, rows: list[dict]) -> None:
    left, top, bar_h, gap = 130, 56, 30, 36
    width, height = left + 400, top + gap * len(rows) + 24
    max_bp = max(r["shareBp"] for r in rows) or 1
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
                             "height": str(height), "viewBox": f"0 0 {width} {height}"})
    ET.SubElement(svg, "text", {"x": str(left), "y": "30", "font-size": "16",
                                "font-family": "sans-serif"}).text = "示例榜单:品类份额(万分比 bp,示例数据)"
    for i, r in enumerate(rows):
        y = top + i * gap
        ET.SubElement(svg, "text", {"x": str(left - 10), "y": str(y + 20), "font-size": "12",
                                    "text-anchor": "end", "font-family": "sans-serif"}).text = r["name"]
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(340 * r["shareBp"] // max_bp),
                                    "height": str(bar_h), "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(left + 348), "y": str(y + 20), "font-size": "12",
                                    "font-family": "sans-serif"}).text = f"{r['shareBp'] / 100:.1f}%"
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def write_pie_svg(path: str, rows: list[dict]) -> None:
    """纯 Python 饼图:按份额万分比切分整圆,用整数度累加避免浮点误差。"""
    size, cx, cy, radius = 460, 200, 230, 150
    palette = ["#2563eb", "#7c3aed", "#db2777", "#ea580c", "#ca8a04",
               "#16a34a", "#0d9488", "#0891b2", "#4f46e5", "#64748b"]
    total = sum(r["heat"] for r in rows) or 1
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(size),
                             "height": str(size), "viewBox": f"0 0 {size} {size}"})
    ET.SubElement(svg, "text", {"x": "20", "y": "26", "font-size": "16",
                                "font-family": "sans-serif"}).text = "品类份额饼图(示例数据)"
    acc = 0
    for i, r in enumerate(rows):
        deg = r["heat"] * 360 // total
        a0, a1 = math.radians(acc), math.radians(acc + deg)
        x0, y0 = cx + radius * math.cos(a0), cy + radius * math.sin(a0)
        x1, y1 = cx + radius * math.cos(a1), cy + radius * math.sin(a1)
        large = 1 if deg > 180 else 0
        color = palette[i % len(palette)]
        ET.SubElement(svg, "path", {
            "d": f"M {cx} {cy} L {x0:.2f} {y0:.2f} A {radius} {radius} 0 {large} 1 {x1:.2f} {y1:.2f} Z",
            "fill": color, "stroke": "#ffffff", "stroke-width": "1"})
        mid = math.radians(acc + deg // 2)
        lx, ly = cx + radius * 0.62 * math.cos(mid), cy + radius * 0.62 * math.sin(mid)
        ET.SubElement(svg, "text", {"x": f"{lx:.2f}", "y": f"{ly:.2f}", "font-size": "10",
                                    "text-anchor": "middle", "fill": "#ffffff",
                                    "font-family": "sans-serif"}).text = r["name"]
        acc += deg
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load_config(os.path.join(args.config, "ch02_rank.json"))
    games, genres = cfg["games"], cfg["genres"]
    total = sum(g["heat"] for g in games)
    os.makedirs(args.out, exist_ok=True)

    rows = genre_share(games, genres, total)
    conc = concentration(games, total, cfg["probe"]["topN"], cfg["probe"]["hhiScale"])

    with open(os.path.join(args.out, "ch02_rank_genre.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["genre", "name", "heat", "shareBp"])
        for r in rows:
            w.writerow([r["genre"], r["name"], r["heat"], r["shareBp"]])

    write_bar_svg(os.path.join(args.out, "ch02_rank_bar.svg"), rows)
    write_pie_svg(os.path.join(args.out, "ch02_rank_pie.svg"), rows)

    report = {
        "source": "sim/ch02_rank_scan.py",
        "observation": cfg["observation"],
        "disclaimer": cfg["disclaimer"],
        "concentration": conc,
        "genreShare": rows,
        "artifacts": ["out/ch02_rank_genre.csv", "out/ch02_rank_bar.svg", "out/ch02_rank_pie.svg"],
    }
    with open(os.path.join(args.out, "ch02_rank_scan.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"concentration": conc, "genreShare": rows}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

同机实跑(30 款示例游戏,节选输出):

concentration: nGames=30 totalHeat=79350 topN=10
               topNShareBp=7006 (70.06%)  hhi=617  hhiLevel=较分散
genreShare(降序): 益智解谜 18.86% | 消除 17.81% | 合成 12.52% | 放置 11.77%
                  模拟经营 10.58% | 休闲竞技 8.84% | 音乐节奏 7.18%
                  塔防 6.36% | 跑酷 5.67% | 动作 0.36%
产物: out/ch02_rank_genre.csv  out/ch02_rank_bar.svg  out/ch02_rank_pie.svg

读法:Top 10 吃掉了约 70% 的份额,但 HHI 只有 617(按 0–10000 口径属「较分散」)。这两条信息看似矛盾,其实互补——头部单看很重,但由于品类多、长尾长,整体还没到寡头垄断。这正是「榜单看着很挤、机会其实还在」的量化证据:如果你的品类不是头部那 2–3 个,靠单品硬刚头部不划算,但在长尾里找一个尚未被占满的细分,空间是存在的。

集中度指标怎么读

把上面两个数字沉淀成可复用的判断口径:

  • Top-N 占比回答「头部有多重」。它是线性量,好懂,但对 N 敏感——同一张榜,Top 5 和 Top 20 能给出完全不同的故事。
  • HHI(赫芬达尔指数)回答「市场是分散还是集中」。它把每家的份额平方后求和,天然放大头部权重,对「一家独大」特别敏感。
  • 两者一起看才不会误判:Top-N 高而 HHI 低,说明头部重但没垄断;Top-N 高且 HHI 也高,才是真正的寡头市场。
HHI(0–10000 口径)结构判读对新进入者的含义
< 1500较分散长尾有位置,选细分比硬刚头部划算
1500 – 2500中等集中需明确的差异化切入点
≥ 2500高集中头部壁垒高,除非换品类或换人群

注意口径:不同平台、不同榜单的 HHI 阈值不能照搬,因为榜单长度、统计口径都不一样。这里的阈值是示例口径下的读法示范,不是行业标准。换一份真实榜单,你应当先用同一套脚本算出它自己的 Top-N 与 HHI,再和你的判断对照,而不是拿一个固定数字到处套。

榜单结构分析的三个常见误区

  1. 把名次当份额。第 3 名和第 4 名的差距可能微乎其微,也可能天差地别——名次不含份额信息,必须自己算。
  2. 只看一张榜。下载榜、畅销榜、热度榜的品类分布常常不同;只看一张,会把「买量型品类」误当成「机会型品类」。
  3. 忽略长尾。Top 10 之外的那 20 款,往往藏着份额虽小但竞争最轻的细分——HHI 低、长尾长的榜,机会恰恰在长尾里。

小结

  • 榜单是结构切片,不是排名表;真正有约束力的是品类分布与集中度。
  • 份额一律用万分比整数、除法整数地板除,跨语言可复算。
  • Top-N 占比看头部重量,HHI 看市场分散度,两者必须一起读。
  • 示例榜单跑出:Top 10 ≈ 70%、HHI ≈ 617(较分散)——头部重但长尾长,机会在长尾。
  • 真实榜单数据属 X(需真实平台后台,命令行不可复现);本节全部为示例数据,算法可直接复用到你的数据。

阅读导航:上一节:市场规模、增长与结构性机会 · 下一节:2.2 竞品拆解方法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制