第 4 章讲完了平台约束,从这一章开始进入「人」:小游戏到底是谁在玩、什么时候玩、一次玩多久。先把分工讲清楚——站内那篇广告变现的清单式短文 列的是「放哪些广告位、频次怎么控」的经验条目;本章不重复那张清单,给的是用户动机与付费意愿的可量化分层模型,本节先落地第一层:画像与使用场景。
画像这件事很容易写空。一份「25–35 岁、喜欢休闲游戏、碎片时间玩」的文档,读起来像那么回事,却指导不了任何设计决策:它既不能回答「首包该做多大」,也不能回答「第一次广告位放第几关」。本节要做的,是把画像从形容词换成一组可加权的数字,让「通勤时玩」这种描述变成「日均打开 4 次、单次 6 分钟、D1 留存 46%」这样的可计算量。
画像不是标签,是一组可加权的数字
小游戏没有端游那样长的会话时长,玩家与游戏的关系高度依赖碎片场景:地铁上两站路、睡前刷手机、排队等一杯奶茶。同一个玩家在不同场景下的行为差异,往往比不同玩家之间的差异还大。所以本节不谈「用户是谁」,只谈「用户处在什么场景、在这个场景下会怎么玩」。
每个画像给五个整数维度:
- 占比(千分比)——这类场景在整体活跃里的份额。
- 日打开次数——一天进来几次。
- 单次时长(分钟)——每次停留多久。
- D1 留存(整数百分比)——次日还回来的比例。
- 人均单次广告次数——一次会话里能自然承接的广告观看量。
这五个数都是示例假设,不是任何产品的后台还原。它们的价值在于:一旦写成数字,就能加权、能比较、能改参重跑,而不是停在形容词层面。
五个示例画像
《合成小岛》是「合成 + 放置 + 关卡 + 装扮」的轻度小游戏,它面对的场景谱比纯消除类更宽。下面这张表给出五类示例画像,覆盖从最短碎片到最长沉浸的整条谱:
| 画像 | id | 占比‰ | 日打开 | 单次分钟 | D1% | 单次广告 |
|---|---|---|---|---|---|---|
| 通勤碎片族 | commute | 320 | 4 | 6 | 46 | 3 |
| 睡前放松族 | bedtime | 260 | 2 | 15 | 41 | 2 |
| 排队等待族 | queue | 180 | 3 | 4 | 52 | 4 |
| 亲子陪伴族 | family | 140 | 1 | 11 | 38 | 1 |
| 深度成就族 | deep | 100 | 5 | 22 | 63 | 2 |
这张表里的每一列都值得读出设计含义。通勤碎片族单次只有 6 分钟,却一天打开 4 次——它决定了核心循环必须能在 6 分钟内跑完一整圈。睡前放松族打开次数少但单次长,它才是放置收益与合成深度的主要消费者。排队等待族单次最短、广告位最多,是 IAA 变现的基本盘。亲子陪伴族打开最少、广告最少,对中断最敏感。深度成就族人数最少却单次时长最高,是长线内容与关卡难度的锚点。
需要强调:占比、时长、留存全部是设计假设(X),来自对公开可观察的小游戏使用习惯的归纳,而非任何后台数据。本节的可复现部分,是「给定这张假设表,加权后的总体指标是多少」;「真实玩家的分布是多少」需要真实业务环境,命令行不可复现。
为什么占比用千分比而不是百分比
一个细节:本节的占比用千分比整数(320 / 260 / 180 / 140 / 100),而不是百分比。原因是小游戏的人群结构里,尾部画像的份额往往小到个位数百分比——比如深度成就族只占 10%。如果占比只用整数百分比,10% 就是最小的可表达粒度,再往下(比如 5% 的某个细分)就只能写 0 或 1,精度不够。
千分比把粒度提高一个数量级,同时保持整数,不引入浮点。这与整套书「config 里禁浮点、跨语言逐位一致」的纪律是一致的:占比、时长、留存全部是整数,Python 与 TypeScript 两侧才能跑到逐位一致的结果。等到第 13 章做收入模型时,同一套千分比占比会再次出现,用来把画像折算成 DAU 结构。
D 层:把画像写进配置
下面这份是 D 层配置。它只做一件事:把上表的五个画像写成整数参数,占比用千分比,避免浮点。它是后面 S 层仿真的唯一输入。
{
"version": 1,
"note": "示例画像:占比用千分比整数;所有数值均为假设参数,真实分布需真实业务环境,命令行不可复现(X)",
"personas": [
{ "id": "commute", "name": "通勤碎片族", "sharePermille": 320, "sessionsPerDay": 4, "minutesPerSession": 6, "d1RetentionPct": 46, "adViewsPerSession": 3 },
{ "id": "bedtime", "name": "睡前放松族", "sharePermille": 260, "sessionsPerDay": 2, "minutesPerSession": 15, "d1RetentionPct": 41, "adViewsPerSession": 2 },
{ "id": "queue", "name": "排队等待族", "sharePermille": 180, "sessionsPerDay": 3, "minutesPerSession": 4, "d1RetentionPct": 52, "adViewsPerSession": 4 },
{ "id": "family", "name": "亲子陪伴族", "sharePermille": 140, "sessionsPerDay": 1, "minutesPerSession": 11, "d1RetentionPct": 38, "adViewsPerSession": 1 },
{ "id": "deep", "name": "深度成就族", "sharePermille": 100, "sessionsPerDay": 5, "minutesPerSession": 22, "d1RetentionPct": 63, "adViewsPerSession": 2 }
]
}
配置里没有浮点:占比、次数、时长、留存、广告数全是整数。千分比占比之和恰好 1000,这是后面加权公式能成立的前提——加权均值 = Σ(占比‰ × 指标) / 1000。
S 层:按占比加权成总体指标
下面这段是 S 层仿真脚本。它读上面那份配置,把每个画像的指标按占比加权,算出五个总体指标,并写出 JSON、CSV 与一张柱状图 SVG。全程整数地板除,没有任何浮点。
#!/usr/bin/env python3
# file: sim/ch05_personas.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch05_personas.py --config config/ --out out/
"""画像加权仿真:读 config/ch05_personas.json 的示例画像表,
按千分比占比把「人均日时长 / 日打开次数 / D1 留存 / 人均日广告次数」
加权成一组总体指标,产出 JSON + CSV + SVG。全程整数地板除,无浮点。
注意:画像与占比均为假设参数,真实分布需真实业务环境,命令行不可复现(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 weighted_permille(rows, share_key, value_key):
"""Σ(share‰ × value),因 Σshare‰=1000,结果即加权均值的千分比(value×1000)。"""
return sum(r[share_key] * r[value_key] for r in rows)
def daily_minutes_permille(r):
"""人均日时长(分钟)的千分比:sessionsPerDay * minutesPerSession。"""
return r["sessionsPerDay"] * r["minutesPerSession"]
def analyze(cfg):
rows = cfg["personas"]
share_sum = sum(r["sharePermille"] for r in rows)
per_persona = []
for r in rows:
per_persona.append(
{
"id": r["id"],
"name": r["name"],
"sharePermille": r["sharePermille"],
"sessionsPerDay": r["sessionsPerDay"],
"minutesPerSession": r["minutesPerSession"],
"dailyMinutes": daily_minutes_permille(r),
"d1RetentionPct": r["d1RetentionPct"],
"adViewsPerSession": r["adViewsPerSession"],
"adViewsPerDay": r["sessionsPerDay"] * r["adViewsPerSession"],
}
)
totals = {
"shareSumPermille": share_sum,
"weightedSessionsPerDayPermille": weighted_permille(rows, "sharePermille", "sessionsPerDay"),
"weightedMinutesPerSessionPermille": weighted_permille(rows, "sharePermille", "minutesPerSession"),
"weightedD1RetentionPctPermille": weighted_permille(rows, "sharePermille", "d1RetentionPct"),
"weightedAdViewsPerSessionPermille": weighted_permille(rows, "sharePermille", "adViewsPerSession"),
"weightedDailyMinutesPermille": sum(p["sharePermille"] * p["dailyMinutes"] for p in per_persona),
}
return {"version": 1, "personas": per_persona, "totals": totals}
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["dailyMinutes"] 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": "#2563eb"})
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['sharePermille']}‰"
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_personas.json")
res = analyze(cfg)
with open(os.path.join(args.out, "ch05_personas.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch05_personas.csv"), "w", encoding="utf-8") as f:
f.write("id,name,sharePermille,dailyMinutes,d1RetentionPct,adViewsPerDay\n")
for r in res["personas"]:
f.write(
f"{r['id']},{r['name']},{r['sharePermille']},{r['dailyMinutes']},"
f"{r['d1RetentionPct']},{r['adViewsPerDay']}\n"
)
size = write_svg(os.path.join(args.out, "ch05_personas.svg"), res["personas"], "《合成小岛》示例画像人均日时长(分钟)")
print(json.dumps(res["totals"], ensure_ascii=False))
print(f"# svg bytes={size}")
if __name__ == "__main__":
main()
脚本除 JSON 外还写出 out/ch05_personas.csv(每个画像的日时长、D1、日广告次数)和 out/ch05_personas.svg(人均日时长柱状图,柱下标出占比)。运行命令:
/opt/homebrew/bin/python3 sim/ch05_personas.py --config config/ --out out/
{"shareSumPermille": 1000, "weightedSessionsPerDayPermille": 2980, "weightedMinutesPerSessionPermille": 10280, "weightedD1RetentionPctPermille": 46360, "weightedAdViewsPerSessionPermille": 2540, "weightedDailyMinutesPermille": 30180}
# svg bytes=1951
读结果:加权后的总体画像
加权指标以千分比输出,除以 1000 就是日常读法。本机(Python 3.14.6)跑出来的结果是:
| 总体指标 | 千分比 | 日常读法 |
|---|---|---|
| 人均日打开次数 | 2980 | 2.98 次/日 |
| 人均单次时长 | 10280 | 10.28 分/次 |
| 加权 D1 留存 | 46360 | 46.36% |
| 人均单次广告次数 | 2540 | 2.54 次/次 |
| 人均日时长 | 30180 | 30.18 分/日 |
三个可以直接读出来的结论:
- 人均日时长约 30 分钟,但它是被长尾拉起来的:深度成就族只占 100‰,日时长却高达 110 分钟;而占 320‰ 的通勤碎片族只有 24 分钟。这说明「人均 30 分钟」这个数字对设计几乎没有指导意义——真正决定内容深度的是那 100‰ 的重度用户,真正决定循环时长的是那 320‰ 的碎片用户。
- 打开次数与单次时长此消彼长:通勤族一天 4 次、每次 6 分钟,睡前族一天 2 次、每次 15 分钟,两者日时长接近(24 对 30 分钟),但对设计的要求完全相反。前者要求循环极短、可随时中断,后者要求有可沉淀的放置收益。
- 留存与沉浸度正相关:D1 从亲子族的 38% 一路升到深度成就族的 63%,加权后 46.36%。这不是巧合——越沉浸的画像,次日回来的动机越强。它给出一条校准线:当你的加权 D1 明显偏离 46% 时,多半是画像结构变了,而不只是「游戏变好了」。
再次强调:以上全部是同机相对量级,绝对数值不可移植,且输入是假设画像而非真实分布。
场景反向决定了哪些设计约束
画像的价值不在画像本身,而在它反向约束了后面的设计。这张假设表至少给出三条硬约束:
- 核心循环必须能在 6 分钟内跑完——否则占 320‰ 的通勤碎片族根本进不来。这条约束会在第 6 章「核心循环」里被再次量化。
- 首包必须够小、冷启动必须够快——碎片场景下玩家在地铁里点开,等 5 秒就会退出。这条在第 4 章已经算过预算,画像只是给出了「为什么是 5 秒而不是 30 秒」的用户侧理由。
- 广告位要能承接「高频短会话」而不是「低频长会话」——排队族一天 3 次、每次看 4 个广告,通勤族一天 4 次、每次 3 个;把广告位设计成「看完一次长视频才给奖励」会直接劝退这两类人。
画像只到假设为止
必须把边界画清楚:本节产出的是一张可加权的假设画像表,不是真实用户数据。它有三个明确的不可复现点(X):真实的占比分布、真实的时长与留存、真实的广告承接量,都需要真实业务环境,命令行不可复现。本节能复现的只有一件事:给定这张表,加权指标是确定的一组整数。
下一节把这批画像再切一刀——同样是「玩」,有人图放松、有人图成就、有人图社交,动机不同,掏钱的意愿也完全不同。那一条切分线,才是付费意愿分层。
阅读导航:上一节:平台能力矩阵与审核红线 · 下一节:动机模型与付费意愿分层 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。