上一章把市场拆成了平台、分发与规模三块,这一章换一个更贴近产品的视角:榜单。榜单是绝大多数小游戏团队唯一能低成本拿到的公开市场信号,但它极易被误读成一张「谁第几名」的排名表。本篇要做的,是把榜单当成一组可切分的结构来读。需要先说清分工:/h5游戏产业全景/ 那篇是 H5 小游戏产业的宏观综述(格局、代表产品、趋势叙事),而本章给的是可量化的榜单结构分析——把榜单变成品类份额与集中度指标,结论可以重跑、可以换数据复算。另一个容易混淆的是 /posts/products/
下的 PRD 系列:那是产品需求文档的写法与样例,本章给的是可跑的数据分析与机会评估脚本,两者一个产出文档、一个产出可执行的量化结论,不要互相替代。
榜单不是排名表,是结构切片
一张「Top 30」榜单里,真正对产品设计有约束力的不是名次,而是三件可计算的事:
- 品类分布:这 30 款里,消除占几款、合成占几款?份额集中在哪几个品类?
- 集中度:头部是不是吃掉了一大半?头部越集中,新进入者的容错空间越小。
- 结构变化:某个品类的份额在涨还是在跌?(需要两期数据,本篇先解决单期切分。)
名次会变、榜单会换,但「这个品类在榜上占多大份额、市场是分散还是集中」这类结构量,跨平台、跨时点都有可比性。这才是我们做产品决策要的输入。
先说一条纪律:真实榜单数据不可得。任何平台的榜单后台、下载量、流水,都属于「需真实业务环境,命令行不可复现」(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,再和你的判断对照,而不是拿一个固定数字到处套。
榜单结构分析的三个常见误区
- 把名次当份额。第 3 名和第 4 名的差距可能微乎其微,也可能天差地别——名次不含份额信息,必须自己算。
- 只看一张榜。下载榜、畅销榜、热度榜的品类分布常常不同;只看一张,会把「买量型品类」误当成「机会型品类」。
- 忽略长尾。Top 10 之外的那 20 款,往往藏着份额虽小但竞争最轻的细分——HHI 低、长尾长的榜,机会恰恰在长尾里。
小结
- 榜单是结构切片,不是排名表;真正有约束力的是品类分布与集中度。
- 份额一律用万分比整数、除法整数地板除,跨语言可复算。
- Top-N 占比看头部重量,HHI 看市场分散度,两者必须一起读。
- 示例榜单跑出:Top 10 ≈ 70%、HHI ≈ 617(较分散)——头部重但长尾长,机会在长尾。
- 真实榜单数据属 X(需真实平台后台,命令行不可复现);本节全部为示例数据,算法可直接复用到你的数据。
阅读导航:上一节:市场规模、增长与结构性机会 · 下一节:2.2 竞品拆解方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。