站内那套 46 篇的小程序专题 讲的是小程序工程口径——怎么把一个功能正确地实现出来;本节讲的是小游戏社交传播面的可量化裂变模型:把 9.1 那张七个面的清单收成一个数,再用这个数推出一条增长曲线。上一节我们算的是「每个传播面各自带来多少新用户」;这一节要算的是它们加起来会把总盘子推向哪里——是越滚越大,还是越滚越小。
一个数:传播系数 K
9.1 的七个传播面,综合转化率是 17‰ 到 90‰。这些数字回答「每个面效率多高」,却回答不了「整体增长会怎样」——因为一个玩家可能同时走好几个面,新玩家自己也会再去传播,这是一个多轮过程。把它压缩,只需要两个量:
- 每用户邀请数(
invitesPerUserPermille):一个玩家平均发出多少条邀请。1.4 条就是 1400‰。 - 接受率(
acceptPermille):收到邀请的人里,多少转化成了新用户。0.3 就是 300‰。
两者相乘,就是传播系数 K:
K = 每用户邀请数 × 接受率 = 1400‰ × 300‰ // 1000 = 420‰
注意单位:这里两个因子都用千分比,乘完再 // 1000,得到的 K 还是千分比。所以 K = 1000‰ 就等价于 K = 1,也就是「一个用户平均带来一个新用户」。这不是一个普通的数值——它是一条分界线:K 小于 1000‰,每一轮传播都在缩小,增长最终归零;K 大于 1000‰,每一轮都在放大,增长会自我加速。下面这份配置把模型固化成可读参数。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "K 因子裂变模型。K = 每用户邀请数(‰) × 接受率(‰) // 1000,单位千分比;K = 1000‰ 为临界点(K < 1000 衰减 / K > 1000 自增长)。invitesPerUserPermille = 每用户邀请条数 × 1000;acceptPermille = 被邀请者转化为新用户的比例。全部为整数千分比假设参数,真实邀请数与接受率需真实业务环境,命令行不可复现(X)。",
"seedUsers": 1000,
"rounds": 12,
"invitesPerUserPermille": 1400,
"acceptPermille": 300,
"kScenariosPermille": [500, 800, 1000, 1200, 1600]
}
配置里有三组东西:基线参数(invitesPerUserPermille 1400、acceptPermille 300,相乘得基线 K = 420‰)、递推参数(seedUsers 1000、rounds 12)、场景档位(kScenariosPermille 从 500‰ 到 1600‰)。
多轮传播的整数递推(S 层)
K 是一个「每轮乘一次」的系数,所以增长是几何递推:第 r 轮的新增,是第 r-1 轮新增乘 K。下面这段是 S 层仿真脚本,读 config/ch09_kfactor.json,对每一档 K 跑一遍整数递推,产出累计用户曲线(JSON + CSV + SVG)。
#!/usr/bin/env python3
# file: sim/ch09_kfactor.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch09_kfactor.py --config config/ --out out/
"""裂变模型与传播系数 K:读 config/ch09_kfactor.json。
K = 每用户邀请数(‰) × 接受率(‰) // 1000,单位千分比,K = 1000‰ 为临界点。
多轮传播用整数递推:第 r 轮新增 new[r] = new[r-1] × K // 1000,累计 cum[r] = cum[r-1] + new[r]。
对 config 里的一组 K 场景各跑一条曲线,产出 JSON + CSV + SVG(累计用户折线)。
全程整数地板除,无浮点。真实邀请数 / 接受率需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
CRITICAL_PERMILLE = 1000
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "ch09_kfactor.json"), encoding="utf-8") as f:
return json.load(f)
def k_of(invites_per_user_permille: int, accept_permille: int) -> int:
"""K = 邀请数 × 接受率 // 1000(单位‰)。"""
return (invites_per_user_permille * accept_permille) // 1000
def propagate(seed_users: int, k_permille: int, rounds: int) -> list[dict]:
"""整数递推多轮传播,返回每轮的 新增 / 累计 序列。"""
rows = []
new = seed_users
cum = 0
for r in range(rounds):
if r > 0:
new = new * k_permille // 1000
cum += new
rows.append({"round": r, "newUsers": new, "cumUsers": cum})
return rows
def draw_svg(path: str, series: list[dict], seed_users: int) -> int:
"""纯 Python SVG:每档 K 一条累计用户折线。"""
W, H, pl, pt, pb = 720, 360, 74, 34, 46
rounds = len(series[0]["curve"])
hi = max(p["cumUsers"] for s in series for p in s["curve"]) or 1
iw, ih = W - pl - 150, H - pt - pb
colors = ["#94a3b8", "#60a5fa", "#22c55e", "#f59e0b", "#dc2626"]
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14",
"fill": "#111827"}).text = "不同 K 值下的累计传播用户(起始 1000 人)"
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(pl + iw), "y2": str(pt + ih),
"stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih),
"stroke": "#9ca3af"})
for i, s in enumerate(series):
pts = [f'{pl + iw * p["round"] // (rounds - 1)},{pt + ih - ih * p["cumUsers"] // hi}'
for p in s["curve"]]
col = colors[i % len(colors)]
ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": col,
"stroke-width": "2"})
ly = pt + 14 + i * 20
ET.SubElement(svg, "rect", {"x": str(W - 138), "y": str(ly - 9), "width": "10",
"height": "10", "fill": col})
ET.SubElement(svg, "text", {"x": str(W - 122), "y": str(ly), "font-size": "11",
"fill": "#374151"}).text = (
f'K={s["kPermille"]}‰ 末轮{s["curve"][-1]["cumUsers"]}'
)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
seed = cfg["seedUsers"]
rounds = cfg["rounds"]
base_k = k_of(cfg["invitesPerUserPermille"], cfg["acceptPermille"])
series = []
for k in cfg["kScenariosPermille"]:
curve = propagate(seed, k, rounds)
series.append({"kPermille": k, "curve": curve,
"finalNew": curve[-1]["newUsers"], "finalCum": curve[-1]["cumUsers"],
"growOrDecay": "grow" if k > CRITICAL_PERMILLE else
("flat" if k == CRITICAL_PERMILLE else "decay")})
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch09_kfactor.svg"), series, seed)
with open(os.path.join(args.out, "ch09_kfactor.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["kPermille", "round", "newUsers", "cumUsers"])
for s in series:
for p in s["curve"]:
w.writerow([s["kPermille"], p["round"], p["newUsers"], p["cumUsers"]])
base_curve = propagate(seed, base_k, rounds)
report = {
"version": cfg["version"],
"game": cfg["game"],
"seed": cfg["seed"],
"seedUsers": seed,
"rounds": rounds,
"invitesPerUserPermille": cfg["invitesPerUserPermille"],
"acceptPermille": cfg["acceptPermille"],
"baseKPermille": base_k,
"criticalPermille": CRITICAL_PERMILLE,
"baseFinalCum": base_curve[-1]["cumUsers"],
"baseFinalNew": base_curve[-1]["newUsers"],
"scenarios": [{"kPermille": s["kPermille"], "finalNew": s["finalNew"],
"finalCum": s["finalCum"], "growOrDecay": s["growOrDecay"]} for s in series],
"csv": os.path.join(args.out, "ch09_kfactor.csv"),
"svg": os.path.join(args.out, "ch09_kfactor.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch09_kfactor.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"baseKPermille": base_k,
"baseFinalCum": report["baseFinalCum"],
"scenarios": [(s["kPermille"], s["finalCum"], s["growOrDecay"]) for s in series],
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"baseKPermille": 420, "baseFinalCum": 1718, "scenarios": [[500, 1994, "decay"], [800, 4644, "decay"], [1000, 12000, "flat"], [1200, 39552, "grow"], [1600, 467356, "grow"]]}
基线 K = 420‰(1400‰ × 300‰ // 1000),12 轮后累计 1718 人——只比起始的 1000 人多了一点点就停了,这就是 K < 1000‰ 的宿命。五档场景把这条规律摊开:500‰ 累计 1994(衰减)、800‰ 累计 4644(衰减)、1000‰ 累计 12000(持平)、1200‰ 累计 39552(自增长)、1600‰ 累计 467356(自增长)。
K = 1000‰ 那行是 12000——起始 1000 人,12 轮每轮恰好补回 1000,累计正好是 1000 × 12,不多不少。这就是「临界」的样子:每一轮新增都等于上一轮,总量线性增长而非指数增长。往左一档(800‰)掉到 4644,往右一档(1200‰)跳到 39552——跨过临界点,量级差了一个数量级。另外注意 K = 500‰ 那行序列 1000, 500, 250, 125, 62, 31, 15, 7, 3, 1, 0, 0:整数地板除让它在第 10 轮真正归零(1 × 500‰ 被地板除成 0),这不是精度损失而是刻意的建模选择——现实中也没有「0.5 个新用户」。
K 参数敏感性:分界线在哪(B 层)
上面只看了五档 K。要回答「分界线到底在哪」,需要把 K 加密扫描。下面这段是 B 层基准脚本:把 K 从 400‰ 到 1800‰ 每 50‰ 一档扫描,找出「末轮新增 ≥ 起始人数」的第一个 K 档;再做归因检验,验证不同(邀请数,接受率)拆分只要乘积相同、曲线就相同。它是基准,无论多少告警退出码恒为 0。
#!/usr/bin/env python3
# file: sim/ch09_kbench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch09_kbench.py --config config/ --out out/
"""B 基准:K 参数敏感性——找出「衰减 / 自增长」的分界线。
两件事:
1) K 扫描:把 K 从 low 到 high 每 step 一档,跑整数递推,记录末轮新增是涨还是落,
找出第一个「末轮新增 >= 起始人数」的 K 档——即自增长起点(理论上应在 1000‰ 附近)。
2) 归因检验:K 是「邀请数 × 接受率」的乘积,验证不同 (邀请数, 接受率) 组合只要 K 相同,
曲线就相同——说明增长只由乘积决定,不被两个因子的具体拆分影响。
软告警:自增长起点偏离 1000‰ 超过容差,或「同 K 不同拆分」曲线不一致,只打 WARN,
退出码恒为 0——这是基准,不是门禁。全整数地板除,无浮点。
"""
from __future__ import annotations
import argparse
import json
import os
from ch09_kfactor import CRITICAL_PERMILLE, k_of, load, propagate
BOUNDARY_TOLERANCE = 100 # 自增长起点与 1000‰ 的允许偏差(‰)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
seed, rounds = cfg["seedUsers"], cfg["rounds"]
# 1) K 扫描:从 400‰ 到 1800‰,每 50‰ 一档
sweep = []
boundary = None
for k in range(400, 1801, 50):
curve = propagate(seed, k, rounds)
final_new = curve[-1]["newUsers"]
grows = final_new >= seed
sweep.append({"kPermille": k, "finalNew": final_new, "finalCum": curve[-1]["cumUsers"],
"grows": grows})
if grows and boundary is None:
boundary = k
# 2) 归因检验:同一 K,不同 (邀请数, 接受率) 拆分,曲线应逐位一致
target_k = cfg["kScenariosPermille"][2] if len(cfg["kScenariosPermille"]) > 2 else 1000
combos = [(invites, target_k * 1000 // invites)
for invites in (1000, 2000, 4000) if invites * 1000 // invites]
combos = [(i, a) for i, a in combos if k_of(i, a) == target_k]
ref = None
attrib = []
for invites, accept in combos:
curve = propagate(seed, target_k, rounds)
cum = [p["cumUsers"] for p in curve]
if ref is None:
ref = cum
attrib.append({"invitesPerUserPermille": invites, "acceptPermille": accept,
"kPermille": k_of(invites, accept), "finalCum": cum[-1],
"sameCurve": cum == ref})
warnings = []
if boundary is None or abs(boundary - CRITICAL_PERMILLE) > BOUNDARY_TOLERANCE:
warnings.append(f"自增长起点 {boundary}‰ 偏离临界 {CRITICAL_PERMILLE}‰ 超过 {BOUNDARY_TOLERANCE}‰")
if any(not a["sameCurve"] for a in attrib):
warnings.append("同 K 不同拆分曲线不一致——增长不应依赖因子拆分")
os.makedirs(args.out, exist_ok=True)
report = {
"version": cfg["version"],
"source": "sim/ch09_kbench.py",
"seedUsers": seed,
"rounds": rounds,
"criticalPermille": CRITICAL_PERMILLE,
"boundaryTolerance": BOUNDARY_TOLERANCE,
"growthBoundaryPermille": boundary,
"sweep": sweep,
"attributionKPermille": target_k,
"attribution": attrib,
"warnings": warnings,
"verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条",
}
with open(os.path.join(args.out, "ch09_kbench.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"growthBoundaryPermille": boundary,
"attributionKPermille": target_k,
"attribution": [(a["invitesPerUserPermille"], a["acceptPermille"], a["sameCurve"])
for a in attrib],
"verdict": report["verdict"],
"warnings": warnings,
}, ensure_ascii=False))
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
执行 /opt/homebrew/bin/python3 sim/ch09_kbench.py --config config/ --out out/,结果如下(退出码 0):
{"growthBoundaryPermille": 1000, "attributionKPermille": 1000, "attribution": [[1000, 1000, true], [2000, 500, true], [4000, 250, true]], "verdict": "PASS(soft)", "warnings": []}
growthBoundaryPermille = 1000——自增长起点精确落在临界点上,与理论一致。边界附近三档最能说明问题:950‰ 末轮新增 565(衰减)、1000‰ 末轮新增 1000(持平)、1050‰ 末轮新增 1704(自增长)。950 到 1050 只差 100‰,末轮新增却从 565 跳到 1704,近三倍——临界点附近就是这么陡,这正是为什么「把 K 从 0.9 抬到 1.1」比「把 K 从 0.4 抬到 0.6」重要得多。
归因检验:K 是乘积,不是两个独立旋钮
B 层还做了一件容易被忽略但很关键的事:归因检验。attribution 那三行是三种不同的参数拆分——(邀请数 1000‰, 接受率 1000‰)、(2000‰, 500‰)、(4000‰, 250‰),乘积都是 K = 1000‰,脚本跑出来曲线逐位一致(sameCurve 全是 true)。这不是巧合,而是模型定义决定的:增长只由乘积决定,不被两个因子的拆分方式影响。
这条结论有直接的运营含义:把接受率翻倍和把邀请数翻倍,对 K 的贡献是一样的。所以「加分享按钮把邀请数从 1.4 抬到 1.8」和「把奖励改成双方都能领、把接受率从 0.3 抬到 0.39」在 K 上等价——该选成本更低、更可持续的那条。通常后者更优:邀请数有天花板(一个人不会无限发),接受率没有(口碑越好越高)。
K 落在哪,决定要不要「烧钱」
把 K 放进业务判断,结论很直接:
- K < 1000‰(比如基线 420‰):自然裂变不足以自我维持,增长必须靠买量或平台流量注入。这时 K 的作用不是「带来增长」,而是降低获客成本——每 1000 个新用户里有 420 个自然来,买量只需买剩下 580 个。
- K ≈ 1000‰:自然裂变刚好持平,用户盘不涨不缩,是最脆弱的区间——任何一次留存下滑都会把 K 推回 1000 以下。
- K > 1000‰:自增长。但要警惕——K > 1 的曲线是指数,现实中会立刻撞到「熟人网络饱和」的天花板(一个人的好友有限,发完就没了),所以真实 K 通常远低于模型外推。
这也是为什么本节的基线 K 只设了 420‰——它不是悲观,而是诚实:绝大多数小游戏的自然 K 都在 0.1 到 0.5 之间。下一节处理 K 的另一面:为了把 K 抬上去,团队最容易走的捷径是强制分享——不给分享就不让继续玩。它短期抬高邀请数,代价却写在留存曲线上,我们要给它建一个能跑的对照模型。
阅读导航:上一节:小游戏特有的社交传播面 · 下一节:社交压力与体验保护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。