站内那套 46 篇的小程序专题 讲的是小程序工程与平台规则口径;本节讲的是小游戏社交传播面的可量化裂变模型的最后一环——体验保护。前两节把「怎么传」讲完了:9.1 拆出七个传播面,9.2 把它们收成传播系数 K。这一节要处理的是 K 的阴暗面:为了让 K 好看,团队最常走的捷径是强制分享——不给分享就不让继续。这一节给这条捷径建一个能跑的对照模型,让「强制 vs 自然」的取舍有数字可依。
强制分享为什么诱人,又为什么危险
强制分享的逻辑很直白:把分享按钮卡在必经路径上——不通关不给下一关、不分享不给体力、不邀请好友不能领奖励。玩家的选择被收窄成一个:要么分享,要么停下。从「邀请数」这个指标看,这招立竿见影:分享触发率能从自然状态的 200‰ 出头,拉到 600‰ 以上。K 的分子被抬起来了,看起来很美。
但代价藏在分母里。每一次强制分享,都是对玩家耐心的一次消耗。一部分玩家会照做,另一部分玩家会直接关掉游戏——而这一部分流失,是永久的:他不会因为「下次可以不分享」而回来,因为他已经走了。于是强制分享同时干了两件相反的事:抬高邀请数,压低留存。而留存是所有传播的基数——基数塌了,再高的触发率也乘不出多少新用户。
平台规则视角:强制分享是被明确限制的
在讨论数值之前,先记住一条公开可观察的平台规则:主流小游戏平台(微信、抖音等)的运营规范里,普遍限制强制分享与诱导分享——典型形态包括「分享后才能继续游戏」「分享后才能解锁功能」「分享才能领取奖励」。这类设计在提审或运营中通常会被判为违规,面临审核不通过、功能被限制、甚至下架的处理。这一点无需数值验证,它是规则约束,不是优化空间。
X 需真实业务环境,命令行不可复现:各平台对「诱导分享」的具体判定边界、处罚力度与申诉流程,属于平台后台与审核口径,命令行无法复现。本节只对强制分享的留存代价做可跑建模,不对平台判定做任何量化承诺。
换句话说,强制分享的问题不只是「划不划算」,还有「合不合规」。但为了把决策讲清楚,下面仍然给它建一个纯数值模型——看清它即使在纯商业账上也不划算,比只讲合规更有说服力。
把两种策略写成两个臂(D 层)
模型把「强制分享」和「自然分享」写成两个策略臂,每个臂有四个参数:每日分享触发率、每日强制提示次数、每次提示的额外流失、以及该策略下的接受率。下面这份配置是 D 层(静态层)产物。
{
"version": 1,
"game": "merge-island",
"note": "社交压力与体验保护模型。把「强制分享」建模为每日额外流失项且接受率被压低,把「自然分享」建模为无额外流失项且接受率较高,对比两种策略的留存曲线与分享拉新。全部为整数千分比假设参数,真实流失率需真实业务环境,命令行不可复现(X)。",
"units": {
"baseChurnPermille": "无社交压力时的每日基础流失(‰)",
"shareTriggerPermille": "每日分享触发率(‰,存活用户中当天发起分享的比例)",
"churnPerPromptPermille": "每次强制分享提示带来的额外流失(‰)",
"acceptPermille": "该策略下被邀请者的接受率(‰)"
},
"horizonDays": 30,
"baseChurnPermille": 60,
"arms": [
{ "id": "forced", "cn": "强制分享", "shareTriggerPermille": 620, "promptsPerDay": 3, "churnPerPromptPermille": 18, "acceptPermille": 180 },
{ "id": "natural", "cn": "自然分享", "shareTriggerPermille": 260, "promptsPerDay": 0, "churnPerPromptPermille": 0, "acceptPermille": 380 }
]
}
两个臂的差异全在参数里:强制臂触发率 620‰、每天 3 次提示、每次带来 18‰ 额外流失,接受率被压到 180‰(被强推的邀请,接收方也反感);自然臂触发率只有 260‰,但没有额外流失,接受率高达 380‰(真朋友的口碑)。注意 baseChurnPermille 是共享的 60‰——两个臂的差别只来自社交压力,不来自别的变量。
逐日递推两条留存曲线(S 层)
下面这段是 S 层仿真脚本,读 config/ch09_pressure.json,对每个臂从 1000‰ 起始留存逐日递推,算出留存曲线、每日分享数、每日拉新,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/ch09_pressure.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch09_pressure.py --config config/ --out out/
"""社交压力与体验保护模型:读 config/ch09_pressure.json。
对每个策略臂(强制分享 / 自然分享)从 1000‰ 起始留存出发逐日递推:
日流失 = baseChurnPermille + promptsPerDay × churnPerPromptPermille
alive[d] = alive[d-1] × (1000 - 日流失) // 1000
shares[d] = alive[d] × shareTriggerPermille // 1000
viralNew[d] = shares[d] × acceptPermille // 1000
输出每臂的留存锚点、累计分享、累计拉新,并画两条留存对照曲线(JSON + CSV + SVG)。
全程整数地板除,无浮点。真实流失率需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "ch09_pressure.json"), encoding="utf-8") as f:
return json.load(f)
def simulate(arm: dict, base_churn: int, horizon: int) -> dict:
churn = base_churn + arm["promptsPerDay"] * arm["churnPerPromptPermille"]
alive = [1000]
shares, viral = [], []
for d in range(1, horizon + 1):
alive.append(alive[-1] * (1000 - churn) // 1000)
for d in range(horizon + 1):
s = alive[d] * arm["shareTriggerPermille"] // 1000
shares.append(s)
viral.append(s * arm["acceptPermille"] // 1000)
return {
"id": arm["id"], "cn": arm["cn"], "dailyChurnPermille": churn,
"alive": alive, "shares": shares, "viralNew": viral,
"cumShares": sum(shares), "cumViral": sum(viral), "cumUserDays": sum(alive),
"d7": alive[7], "d30": alive[min(30, horizon)],
}
def draw_svg(path: str, arms: list[dict], horizon: int) -> int:
"""纯 Python SVG:两条留存对照曲线(‰)。"""
W, H, pl, pr, pt, pb = 680, 380, 70, 130, 34, 46
hi = max(a["alive"][0] for a in arms) or 1
iw, ih = W - pl - pr, H - pt - pb
colors = {"forced": "#dc2626", "natural": "#16a34a"}
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 = "强制分享 vs 自然分享:示例留存对照曲线(‰)"
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(W - pr), "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, a in enumerate(arms):
pts = []
for d, v in enumerate(a["alive"]):
sx = pl + iw * d // horizon
sy = pt + ih - ih * v // hi
pts.append(f"{sx},{sy}")
col = colors.get(a["id"], "#6b7280")
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 - pr + 12), "y": str(ly - 9), "width": "10",
"height": "10", "fill": col})
ET.SubElement(svg, "text", {"x": str(W - pr + 28), "y": str(ly), "font-size": "11",
"fill": "#374151"}).text = f'{a["cn"]} D30={a["d30"]}‰'
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)
horizon = cfg["horizonDays"]
arms = [simulate(a, cfg["baseChurnPermille"], horizon) for a in cfg["arms"]]
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch09_pressure.svg"), arms, horizon)
with open(os.path.join(args.out, "ch09_pressure.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["arm", "day", "alivePermille", "shares", "viralNew"])
for a in arms:
for d in range(horizon + 1):
w.writerow([a["id"], d, a["alive"][d], a["shares"][d], a["viralNew"][d]])
# 逐日拉新首次反超点(自然 arm 的 viralNew 超过强制 arm 的那一天)
forced = next(a for a in arms if a["id"] == "forced")
natural = next(a for a in arms if a["id"] == "natural")
crossover = next((d for d in range(horizon + 1)
if natural["viralNew"][d] > forced["viralNew"][d]), None)
report = {
"version": cfg["version"],
"game": cfg["game"],
"horizonDays": horizon,
"baseChurnPermille": cfg["baseChurnPermille"],
"arms": [{k: a[k] for k in ("id", "cn", "dailyChurnPermille", "d7", "d30",
"cumShares", "cumViral", "cumUserDays")} for a in arms],
"dailyViralCrossoverDay": crossover,
"csv": os.path.join(args.out, "ch09_pressure.csv"),
"svg": os.path.join(args.out, "ch09_pressure.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch09_pressure.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({
"arms": [(a["cn"], a["dailyChurnPermille"], a["d30"], a["cumShares"], a["cumViral"])
for a in arms],
"dailyViralCrossoverDay": crossover,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"arms": [["强制分享", 114, 23, 5237, 925], ["自然分享", 60, 149, 3651, 1373]], "dailyViralCrossoverDay": 3}
三条结论
把输出展开:强制臂日流失 114‰(60‰ 基础 + 3 × 18‰ 压力),D30 留存 23‰,累计分享 5237,累计拉新 925;自然臂日流失 60‰,D30 留存 149‰,累计分享 3651,累计拉新 1373。逐日拉新第 3 天就被反超(dailyViralCrossoverDay = 3)。三条结论。
第一,强制的分享优势是真的,但极短。 强制臂的累计分享是 5237,自然臂只有 3651——强制多发了 43% 的分享。看单日更清楚:
day, 强制 alive, 强制 shares, 强制 viralNew, 自然 alive, 自然 shares, 自然 viralNew
0, 1000, 620, 111, 1000, 260, 98
1, 886, 549, 98, 940, 244, 92
2, 784, 486, 87, 883, 229, 87
3, 694, 430, 77, 830, 215, 81
7, 426, 264, 47, 647, 168, 63
30, 23, 14, 2, 149, 38, 14
强制臂每天发的分享确实更多(第 0 天 620 vs 260,两倍多)。但拉新(viralNew)只在前两天领先:第 0 天 111 vs 98,第 1 天 98 vs 92,到第 3 天就被自然臂反超(77 vs 81)。原因是强制臂的接受率只有 180‰——它逼出来的分享,接收方不爱点。高触发率被低接受率几乎抵消掉了。
第二,留存塌陷是持续且加速的。 强制臂的 D30 留存只有 23‰,自然臂是 149‰——差了六倍多。而且这个差距是复利式的:强制臂每天比自然臂多流失 54‰,这个差额日积月累,到第 30 天,两条曲线已经天差地别。累计用户日(cumUserDays)更直观:强制臂 8471,自然臂 14092——强制臂少了 40% 的用户日。用户日是未来所有变现的基数,塌在这里,后面全跟着塌。
第三,纯商业账上,强制也不划算。 把两个核心指标放一起:
| 指标 | 强制分享 | 自然分享 | 谁赢 |
|---|---|---|---|
| 累计分享 | 5237 | 3651 | 强制(+43%) |
| 累计拉新 | 925 | 1373 | 自然(+48%) |
| D30 留存 | 23‰ | 149‰ | 自然(六倍) |
| 累计用户日 | 8471 | 14092 | 自然(+66%) |
强制只在「发了多少分享」这一项领先,而在真正重要的三项(拉新、留存、用户日)全面落后。换句话说,强制分享买到的是一个虚荣指标——分享数好看,但分享的质量低、成本高。这还没算上合规风险。
体验保护的四个设计原则
从模型反推,体验保护不是一句口号,而是四条可执行的设计原则:
第一,分享是「加一条路」,不是「锁一道门」。 强制臂的本质是阻塞——不分享就不让走。自然臂的本质是增益——分享能多拿一点,不分享也能玩。任何「分享才能 X」的设计,都要改成「分享能更快 / 更多 X」。这条直接对应平台的诱导分享规则,也直接对应模型里那 54‰ 的额外流失。
第二,接受率是分享设计的核心指标,不是触发率。 强制臂把触发率拉到 620‰,却把接受率压到 180‰,最后拉新反而更低。设计该优化的是「收到邀请的人愿不愿意来」——让奖励对接收方也成立(双方都领),让邀请文案来自玩家自己(而不是模板),让被邀请者一进来就有内容可玩。
第三,把「分享」和「留存」放在同一张表里看。 只盯分享数,一定会滑向强制;把 D7 / D30 / 用户日一起摆出来,强制的账立刻就露馅了。本节的对照模型就是把这两类指标并排递推,让取舍可见。
第四,给分享设一个「不打扰」的节流。 强制臂的参数是「每天 3 次提示」,如果改成「每天最多 1 次、且只在玩家主动想继续时出现」,额外流失会显著下降。分享提示的频率,本身就是一个需要调参的设计变量——不是越多越好。
小结
本节给 9.2 的 K 补上了最后一环:K 的分子(邀请数)可以靠强制分享硬拉,但代价写在分母(留存基数)和接受率上。对照模型的结果很干脆——强制的分享优势只维持三天,之后在拉新、留存、用户日三项上全面落后。社交设计的目标不是「让玩家分享」,而是「让玩家愿意分享、也让被邀请的人愿意来」——这才是可持续的 K。
到这里,卷一的社交与裂变机制讲完了:9.1 拆面、9.2 收成 K、9.3 加保护。下一章转入变现设计前置,讲广告形态怎么放、内购结构怎么搭、以及变现与体验怎么平衡——具体到 eCPM 的建模,那是卷三第 1 到 3 章的事。
阅读导航:上一节:裂变模型与传播系数 K · 下一节:IAA 广告形态与场景布局 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。