上一节把用户按使用场景分了画像,但场景只回答「什么时候玩、玩多久」,不回答「为什么玩、愿不愿意花钱」。本节做的是另一条正交的切分线:动机,以及由动机派生的付费意愿。先把分工讲清楚——站内那篇广告变现的清单式短文 给的是「广告位怎么放、频次怎么控」的经验清单;本章给的是用户动机与付费意愿的可量化分层模型,本节落地其中的「意愿分层」这一层。
动手前划两条硬边界。第一,本节只做意愿分层与人群画像,不做任何付费点设计——首充、礼包、通行证这些具体付费点的结构与定价,是第 10 章(变现设计前置)和卷三(上线变现与长线运营)的内容,本节一个付费点的位置都不写。第二,本节不碰 LTV 回本建模(那是卷三第 3 章),只回答「人群怎么按动机分层、每层愿意花多少钱」。
动机不等于钱包
很多团队会把「付费玩家」直接等同于「已经充过钱的人」。这个口径对设计几乎没有指导意义:一个玩家没充钱,可能是不想充,也可能是还没遇到值得充的时刻。这两种情况在数据看板上长得一模一样,在设计上却完全不同。
所以本节引入一个中间变量:动机层(motivation tier)。它描述玩家「为了什么而玩」,与「是否已经掏过钱」无关。动机决定了玩家会在什么时刻、为什么理由愿意付费:收集型玩家会为「差最后一块拼图」付费,效率型玩家会为「跳过等待」付费,竞技型玩家会为「排名与强度」付费。分层就是按动机给人群切几刀,再给每层一个付费率与一个 ARPPU。
需要强调:每层的付费率与 ARPPU 都是假设参数(X),来自对公开可观察的小游戏变现结构的归纳,不是任何产品的后台还原。本节的可复现部分,是「给定这套分层参数,总体 ARPDAU 是多少」;「真实人群的付费率是多少」需要真实业务环境,命令行不可复现。
五层动机与付费意愿分层
《合成小岛》把用户按「主导动机」切成五层,覆盖从完全不付费到高付费的整条谱:
| 层 | id | 主导动机 | 设计含义(公开可观察的运营共识) |
|---|---|---|---|
| 社交白嫖型 | social-free | 图热闹、蹭社交 | 几乎不付费,靠玩法留存与分享贡献活跃 |
| 收集完成型 | collector | 集齐图鉴、完成度 | 为「差一点」付费,对小额点敏感 |
| 效率加速型 | accelerator | 跳过等待、加速进度 | 为省时间付费,是放置类的主力付费层 |
| 装扮展示型 | decorator | 个性化、炫耀 | 为外观与展示付费,对稀缺敏感 |
| 竞技攀比型 | competitor | 排名、对抗 | 为强度与排名付费,对体验中断敏感 |
这张表里的付费率与 ARPPU 都是设计假设(X),不是对任何具体产品的还原。它的价值在于把「大 R / 小 R」这类模糊说法,换成一条有刻度、可仿真、可校准的分层轴。
注意五层里**「社交白嫖型」单独一层且付费率为 0**——这不是凑数,而是刻意把「不付费的大多数」显式建模出来。它占 420‰,接近一半;如果把它混进平均值里,会系统性地高估付费率。分层的第一个作用,就是让不付费的人不被平均数掩盖。
D 层:把分层与参数写进配置
下面这份是 D 层配置。它把五层的占比(千分比)、付费率(整数百分比)与 ARPPU(单位:分)写成整数参数。付费率用整数百分比、ARPPU 用整数分,是为了全程避免浮点。
{
"version": 1,
"note": "示例分层:付费率与 ARPPU 均为整数假设参数(ARPPU 单位为分);真实分布需真实业务环境,命令行不可复现(X)",
"tiers": [
{ "id": "social-free", "name": "社交白嫖型", "sharePermille": 420, "payRatePct": 0, "arppuFen": 0 },
{ "id": "collector", "name": "收集完成型", "sharePermille": 240, "payRatePct": 9, "arppuFen": 1500 },
{ "id": "accelerator", "name": "效率加速型", "sharePermille": 160, "payRatePct": 14, "arppuFen": 2200 },
{ "id": "decorator", "name": "装扮展示型", "sharePermille": 120, "payRatePct": 11, "arppuFen": 1800 },
{ "id": "competitor", "name": "竞技攀比型", "sharePermille": 60, "payRatePct": 18, "arppuFen": 3000 }
]
}
配置里没有浮点:占比、付费率、ARPPU 全是整数。千分比占比之和恰好 1000,这是后面公式能成立的前提。ARPPU 用「分」而不是「元」,是为了让 15 元、22 元这样的价格也能写成整数,避免 15.5 这类小数。
S 层:从分层参数算出总体 ARPDAU
下面这段是 S 层仿真脚本。它读上面那份配置,用三个加权量算出总体付费率、总体 ARPPU 与总体 ARPDAU,并写出 JSON、CSV 与一张 ARPPU 柱状图 SVG。全程整数地板除,没有任何浮点。
#!/usr/bin/env python3
# file: sim/ch05_motivation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch05_motivation.py --config config/ --out out/
"""动机 → 付费意愿分层模型:读 config/ch05_motivation.json 的示例分层,
每层给出「占比(千分比)+ 付费率(整数百分比)+ ARPPU(分)」,算出
总体付费率、总体 ARPPU 与总体 ARPDAU,产出 JSON + CSV + SVG。
全程整数地板除,无浮点。
注意:付费率与 ARPPU 均为假设参数,真实分布需真实业务环境,命令行不可复现(X)。
"""
import argparse
import json
import os
import xml.etree.ElementTree as ET
def load_config(config_dir, name):
with open(os.path.join(config_dir, name), encoding="utf-8") as f:
return json.load(f)
def analyze(cfg):
rows = cfg["tiers"]
share_sum = sum(r["sharePermille"] for r in rows)
# 每层的「付费权重」= 占比(‰) × 付费率(%),单位是「‰·%」,全体加总后 //10 得整体付费率(‰)
pay_weight = sum(r["sharePermille"] * r["payRatePct"] for r in rows)
# 每层的收入权重 = 占比(‰) × 付费率(%) × ARPPU(分),单位是「‰·%·分」
rev_weight = sum(r["sharePermille"] * r["payRatePct"] * r["arppuFen"] for r in rows)
payer_share_permille = pay_weight // 10 # 整体付费率,千分比
arppu_fen = rev_weight // pay_weight if pay_weight else 0 # 付费用户平均 ARPPU(分)
arpdau_fen = rev_weight // (1000 * 100) # 全体日活 ARPDAU(分)
per_tier = []
for r in rows:
contrib = r["sharePermille"] * r["payRatePct"] * r["arppuFen"] // (1000 * 100)
per_tier.append(
{
"id": r["id"],
"name": r["name"],
"sharePermille": r["sharePermille"],
"payRatePct": r["payRatePct"],
"arppuFen": r["arppuFen"],
"contributionFen": contrib,
}
)
contrib_sum = sum(t["contributionFen"] for t in per_tier)
return {
"version": 1,
"shareSumPermille": share_sum,
"payerSharePermille": payer_share_permille,
"arppuFen": arppu_fen,
"arpdauFen": arpdau_fen,
"contributionSumFen": contrib_sum,
"roundingGapFen": arpdau_fen - contrib_sum,
"tiers": per_tier,
}
def write_svg(path, rows, title):
W, H, pl, pr, pt, pb = 640, 360, 72, 24, 32, 56
labels = [r["name"] for r in rows]
values = [r["arppuFen"] for r in rows]
hi = max(values) or 1
iw, ih, n = W - pl - pr, H - pt - pb, len(labels)
svg = ET.Element(
"svg",
{"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)},
)
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
t = ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14", "fill": "#111827"})
t.text = title
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(W - pr), "y2": str(pt + ih), "stroke": "#9ca3af"})
slot = iw // n
bw = (iw // n) * 2 // 3
for i, r in enumerate(rows):
cx = pl + slot * i + slot // 2
h = ih * values[i] // hi
ET.SubElement(svg, "rect", {"x": str(cx - bw // 2), "y": str(pt + ih - h), "width": str(bw), "height": str(h), "fill": "#059669"})
v = ET.SubElement(svg, "text", {"x": str(cx), "y": str(pt + ih - h - 6), "font-size": "11", "text-anchor": "middle", "fill": "#111827"})
v.text = str(values[i])
lab = ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - pb + 18), "font-size": "11", "text-anchor": "middle", "fill": "#374151"})
lab.text = labels[i]
sh = ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - pb + 34), "font-size": "10", "text-anchor": "middle", "fill": "#6b7280"})
sh.text = f"{r['payRatePct']}%"
xml = ET.tostring(svg, encoding="unicode")
with open(path, "w", encoding="utf-8") as f:
f.write(xml)
ET.fromstring(xml)
return len(xml.encode())
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load_config(args.config, "ch05_motivation.json")
res = analyze(cfg)
with open(os.path.join(args.out, "ch05_motivation.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch05_motivation.csv"), "w", encoding="utf-8") as f:
f.write("id,name,sharePermille,payRatePct,arppuFen,contributionFen\n")
for t in res["tiers"]:
f.write(
f"{t['id']},{t['name']},{t['sharePermille']},{t['payRatePct']},"
f"{t['arppuFen']},{t['contributionFen']}\n"
)
size = write_svg(os.path.join(args.out, "ch05_motivation.svg"), res["tiers"], "《合成小岛》示例分层 ARPPU(分)")
summary = {k: res[k] for k in ("payerSharePermille", "arppuFen", "arpdauFen", "contributionSumFen", "roundingGapFen")}
print(json.dumps(summary, ensure_ascii=False))
print(f"# svg bytes={size}")
if __name__ == "__main__":
main()
脚本除 JSON 外还写出 out/ch05_motivation.csv(每层的付费率、ARPPU 与收入贡献)和 out/ch05_motivation.svg(每层 ARPPU 柱状图,柱下标出付费率)。运行命令:
/opt/homebrew/bin/python3 sim/ch05_motivation.py --config config/ --out out/
{"payerSharePermille": 680, "arppuFen": 2027, "arpdauFen": 137, "contributionSumFen": 136, "roundingGapFen": 1}
# svg bytes=1929
读结果:一个高免费、长尾付费的结构
在本机(Python 3.14.6)跑出来的结果是:
| 层 | 占比‰ | 付费率% | ARPPU(分) | 收入贡献(分/日活) |
|---|---|---|---|---|
| 社交白嫖型 | 420 | 0 | 0 | 0 |
| 收集完成型 | 240 | 9 | 1500 | 32 |
| 效率加速型 | 160 | 14 | 2200 | 49 |
| 装扮展示型 | 120 | 11 | 1800 | 23 |
| 竞技攀比型 | 60 | 18 | 3000 | 32 |
| 总体 | 1000 | 6.8 | 2027 | 137 |
四个可以直接读出来的结论:
- 总体付费率 6.8%、总体 ARPPU 20.27 元、总体 ARPDAU 1.37 元。这三个数是同一套分层参数的三张面孔:付费率描述「多少人掏钱」,ARPPU 描述「掏钱的人平均花多少」,ARPDAU 描述「平摊到每个日活上每天赚多少」。
- 付费率随动机单调上升,但人数单调下降:竞技攀比型付费率最高(18%),却只占 60‰;社交白嫖型占 420‰,付费率为 0。付费率与人群规模反向,这是分层模型最有价值的一条结构。
- 收入贡献高度集中在两层:效率加速型(49 分)与收集完成型、竞技攀比型(各 32 分)合计贡献了绝大部分。这三层加起来只占 460‰,却撑起了几乎全部收入——不到一半的人在养整个盘子。
- 付费率不是 ARPPU 的替代品:装扮展示型付费率(11%)低于效率加速型(14%),ARPPU(1800 分)也低于它(2200 分),收入贡献自然只有 23 分。付费率与 ARPPU 是两个独立旋钮,设计时不能只看一个。
再次强调:以上全部是同机相对量级,绝对数值不可移植,且输入是假设参数而非真实后台数据。
还要注意:这套模型只用到「占比 × 付费率 × ARPPU」三层乘法,它不预测流水。真实的 ARPDAU 还受广告变现(第 10 章)、付费点结构(卷三第 2 章)与留存曲线(下一节)共同影响。本节给的是内购侧的一个下界分量,把它当成整体收入会系统性低估。
整数地板除留下的 1 分钱缺口
结果里有一个容易被忽略、但必须点出来的细节:arpdauFen 是 137,而五层收入贡献相加是 contributionSumFen = 136,两者差 1 分(roundingGapFen = 1)。
这不是 bug,而是整数地板除的必然结果。总体 ARPDAU 用一次除法 rev_weight // 100000 算出,得到 137;而每层贡献各自先做一次 // 100000 再相加,五次的截断误差累积,得到 136。先除后加 ≠ 先加后除,在整数运算里这两者可以差 1 个最小单位。
这个 1 分钱本身不重要,重要的是它提醒一件事:当你在报表里看到「分项之和 ≠ 合计」时,先别急着找数据 bug——先确认两侧是不是用了不同的取整口径。整套书坚持「整数地板除」,就是为了让这类缺口可预期、可复算,而不是让浮点误差藏在第 6 位小数里。第 13 章做收入模型时,同一个缺口会以「分层收入之和 vs 总体 ARPDAU」的形式再次出现。
分层只到画像为止
必须再强调一次边界:本节产出的是意愿分层与人群画像,不是付费点方案。知道了「谁是高意愿人群」,接下来「给他们设计什么付费点、定什么价、放什么位置」是另外两章的事——具体付费点结构在第 10 章,LTV 与付费点的映射建模在卷三第 3 章。本节只保证一件事:当你在后续章节里说「大 R」时,背后有一条由动机切开的五层轴,和一份可复现的分层参数。
画像与意愿回答了「谁在玩、谁愿意花钱」,但它们都是某个时间点的横截面。真实玩家是流动的——同一个人在第 1 天、第 7 天、第 30 天的行为完全不同。下一节就把时间轴加上,讲生命周期阶段与留存结构。
阅读导航:上一节:小游戏用户画像与使用场景 · 下一节:生命周期阶段与留存结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。