同样地,站内那篇 小游戏开发综述
是平台能力与留存综述,本节给的是可跑的引导漏斗模型:那篇讲「留存曲线长什么样」,本节把新手引导这一段拆成逐步到达率,用同一份 config/e13_tutorial.json 跑出「每一步还剩多少人」。上一节的状态机管单个人的合法性,本节管一群人的分布。
引导漏斗和常见的「新增 → 次日留存」漏斗不是一回事。留存漏斗的每一层是时间(第 0 天、第 1 天、第 7 天),而引导漏斗的每一层是步骤(点棋盘、合成、开商店、购买、摆放)。步骤级漏斗的好处是可归因:如果完成率整体偏低,你能精确知道是第 3 步掉的,而不是笼统地说「新手留存差」。而且引导漏斗里有两类留存漏斗没有的分支——跳过(玩家主动绕过某步)和卡住重试(失败后重来,可能成功也可能放弃)。
漏斗的三条分支
每一步玩家有四种去向:完成(正常过关)、跳过(该步可跳过且玩家选择绕过)、重试后完成(先失败,重试若干次后成功)、流失(失败次数用尽或直接退出)。把「卡住重试」单独建模很关键:引导里很多流失不是「不会做」,而是「试了两次没成功就走了」——只看完成/未完成会把这类人误判成「不会做」,从而做出错误的引导设计。
S 层:引导漏斗仿真
下面这段是 S 层仿真脚本 sim/e13_funnel_sim.py:它读同一份 config/e13_tutorial.json,用可播种 LCG 逐玩家逐步骤推进,每一步依次掷「跳过 / 失败重试 / 直接流失」三条分支。脚本跑两遍做对照——allow_skip=True(可跳过步骤能跳)与 allow_skip=False(所有步骤强制完成),两次用同一 seed,差异只来自「能不能跳过」这一个变量。产出 JSON + CSV + 纯 Python SVG。注意它没有 import random:随机一律来自配置里的 seed 加整数 LCG,同一 seed 每次得到同一批玩家。
#!/usr/bin/env python3
# file: sim/e13_funnel_sim.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e13_funnel_sim.py --config config/ --out out/
"""S 层仿真:新手引导漏斗。
同一份 config/e13_tutorial.json 被 TS 内核(core/src/e13/tutorial.ts)与本仿真消费:
内核回答「这一步合法吗」,本仿真回答「一万个玩家走到哪一步会掉」。
用可播种 LCG 逐玩家逐步骤推进,每个步骤含三条分支——跳过 / 失败后重试 / 直接流失——
再跑两遍做对照:允许「可跳过步骤跳过」与「强制逐步完成」的最终完成率差异。
产出 JSON + CSV + 纯 Python SVG。全整数、无浮点;随机一律来自 seed + 整数 LCG。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
MULT, INC, MASK = 1103515245, 12345, 0x7FFFFFFF
def lcg(state: int) -> int:
return (state * MULT + INC) & MASK
def load(config_dir: str) -> dict:
with open(os.path.join(config_dir, "e13_tutorial.json"), encoding="utf-8") as f:
return json.load(f)
def run_variant(cfg: dict, allow_skip: bool) -> dict:
"""跑一遍漏斗:allow_skip=False 时忽略 skipPermille,所有步骤强制完成。"""
fun = cfg["funnel"]
steps = cfg["steps"]
players = fun["players"]
retry_max = fun["retryMax"]
retry_ok = fun["retrySuccessPermille"]
per = fun["steps"]
n = len(steps)
reached = [0] * (n + 1) # 到达第 i 步的人数;下标 n = 走完
completed = [0] * n
skipped = [0] * n
retried = [0] * n
dropped = [0] * n
state = cfg["seed"]
for _ in range(players):
i = 0
while i < n:
reached[i] += 1
step = steps[i]
p = per[step["id"]]
state = lcg(state)
# 分支一:可跳过步骤被玩家主动跳过(对照变体忽略此分支)
if allow_skip and step["skippable"] and state % 1000 < p["skipPermille"]:
skipped[i] += 1
i += 1
continue
state = lcg(state)
# 分支二:失败后重试,重试若干次仍失败则放弃
if state % 1000 < p["failPermille"]:
ok = False
for _r in range(retry_max):
state = lcg(state)
if state % 1000 < retry_ok:
ok = True
break
if ok:
retried[i] += 1
completed[i] += 1
i += 1
continue
dropped[i] += 1
break
state = lcg(state)
# 分支三:非失败情形下的直接流失
if state % 1000 < p["abandonPermille"]:
dropped[i] += 1
break
completed[i] += 1
i += 1
if i >= n:
reached[n] += 1
return {
"allowSkip": allow_skip,
"reached": reached,
"completed": completed,
"skipped": skipped,
"retried": retried,
"dropped": dropped,
"finished": reached[n],
"finishPermille": reached[n] * 1000 // players,
}
def write_svg(path: str, a: dict, b: dict) -> int:
W, H, PAD = 520, 300, 44
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": str(PAD), "y": "24", "font-size": "15", "fill": "#0f172a"},
).text = "引导漏斗完成率对照(千分比)"
bars = [("可跳过", a["finishPermille"], "#38bdf8"), ("强制完成", b["finishPermille"], "#f59e0b")]
top = 1000
plot_h = H - 2 * PAD
bw = (W - 2 * PAD) // len(bars)
for i, (label, v, color) in enumerate(bars):
bh = v * plot_h // top
x = PAD + i * bw + bw // 4
ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - PAD - bh), "width": str(bw // 2),
"height": str(bh), "fill": color})
ET.SubElement(svg, "text", {"x": str(x + bw // 4), "y": str(H - PAD - bh - 6),
"font-size": "13", "text-anchor": "middle", "fill": "#0f172a"}, ).text = str(v)
ET.SubElement(svg, "text", {"x": str(x + bw // 4), "y": str(H - PAD + 18),
"font-size": "13", "text-anchor": "middle", "fill": "#334155"}, ).text = label
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)
os.makedirs(args.out, exist_ok=True)
steps = cfg["steps"]
ids = [s["id"] for s in steps]
with_skip = run_variant(cfg, True)
force_all = run_variant(cfg, False)
report = {
"version": 1, "source": "sim/e13_funnel_sim.py", "tier": "S",
"players": cfg["funnel"]["players"], "seed": cfg["seed"], "steps": ids,
"withSkip": with_skip, "forceAll": force_all,
"finishDeltaPermille": with_skip["finishPermille"] - force_all["finishPermille"],
}
with open(os.path.join(args.out, "e13_funnel_sim.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e13_funnel_sim.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["variant", "step", "reached", "completed", "skipped", "retried", "dropped"])
for variant, r in (("withSkip", with_skip), ("forceAll", force_all)):
for i, sid in enumerate(ids):
w.writerow([variant, sid, r["reached"][i], r["completed"][i],
r["skipped"][i], r["retried"][i], r["dropped"][i]])
svg_bytes = write_svg(os.path.join(args.out, "e13_funnel_sim.svg"), with_skip, force_all)
report["svgBytes"] = svg_bytes
print(json.dumps({
"players": cfg["funnel"]["players"],
"withSkipFinishPermille": with_skip["finishPermille"],
"forceAllFinishPermille": force_all["finishPermille"],
"deltaPermille": report["finishDeltaPermille"],
"withSkip": {"reached": with_skip["reached"], "skipped": with_skip["skipped"],
"retried": with_skip["retried"], "dropped": with_skip["dropped"]},
"svgBytes": svg_bytes,
}, ensure_ascii=False, indent=2))
print(f"== 引导漏斗:可跳过完成率 {with_skip['finishPermille']}‰ / 强制完成 {force_all['finishPermille']}‰"
f"(差 {report['finishDeltaPermille']}‰,同机相对量级,绝对数值不可移植)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ /opt/homebrew/bin/python3 sim/e13_funnel_sim.py --config config/ --out out/
{
"players": 20000,
"withSkipFinishPermille": 813,
"forceAllFinishPermille": 787,
"deltaPermille": 26,
"withSkip": {
"reached": [20000, 19306, 18494, 17811, 16952, 16277],
"skipped": [0, 3546, 2754, 3885, 0],
"retried": [1260, 1845, 1457, 1865, 1221],
"dropped": [694, 812, 683, 859, 675]
},
"svgBytes": 727
}
== 引导漏斗:可跳过完成率 813‰ / 强制完成 787‰(差 26‰,同机相对量级,绝对数值不可移植)
读这张漏斗
两万玩家进引导,逐步到达人数是 20000 → 19306 → 18494 → 17811 → 16952 → 16277:第一步就掉了 694 人(tap_board 不可跳过,这些人连棋盘都没点就走了),这是引导最贵的一步。看跳过列:merge_two 被跳 3546 人、open_shop 2754、buy_item 3885——三个可跳过步骤各有约 15%–22% 的玩家选择绕过,说明这些装饰性操作对相当一部分人来说是「多余的」。看重试列:每步都有 1200–1900 人是「失败后重试成功」的,如果不单独统计,这批人会被误算成流失。
最有价值的是两遍的对照:可跳过时最终完成率 813‰,强制完成时只有 787‰,差 26‰。这 26‰ 就是「允许跳过」这件事本身的价值——那些本来会卡在 merge_two 或 buy_item 的人,因为能跳过而继续走到了引导结束。这个差值不大(不到 3 个百分点),但它回答了一个具体决策:「给不给玩家跳过权」不是拍脑袋,而是可以量化的一格收益。反过来,如果某个可跳过步骤的 skipped 高得离谱(比如 50%),那说明这一步的设计本身有问题,跳过只是掩盖了它。
out/e13_funnel_sim.svg 是完成率对照柱状图(可跳过 vs 强制完成),可以直接贴进评审材料。
埋点口径
要让这套仿真对得上真实埋点,事件口径必须和状态机的 trigger 一一对应:first_tap、merge_done、shop_open、purchase_done、place_done 五个事件名既是状态机的完成条件,也是埋点上报的 event 字段。这样线上跑出来的每一步到达率,可以直接和仿真里的 reached 数组对照——仿真参数(failPermille / skipPermille / abandonPermille)就是用线上数据反标定出来的。真实平台的埋点后台数据需要真实业务环境,命令行不可复现(标 X);本节给的是可复现的模型侧:改一个千分比参数,立刻能看到完成率怎么动。
小结与下一步
这一节把引导从「一个人的合法性」扩展成「一群人的分布」,并用两遍对照量化了「可跳过」的价值。但漏斗只是测量:它告诉你玩家在第 3 步掉得多,却没告诉你怎么救。下一节给出救法——渐进式教学:失败越多、提示越强,卡住自动给提示,再配一个撤销栈容忍误操作。
阅读导航:上一节:引导系统与状态机 · 下一节:渐进式教学与容错 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。