先划边界。站内的 PRD 系列 产出的是需求文档——它描述「要做什么功能、字段长什么样、流程怎么走」;本章的原型是可跑、可调参、可断言、可浏览器打开的工程。PRD 回答「产品应该长什么样」,本章回答「这个玩法到底值不值得往下做」:前者是纸面约定,后者必须能在机器上跑出数字来。前两章我们已经把《合成小岛》的核心循环落成了一个能跑的最小原型(见最小可玩原型 ),也做了立项决策与止损线;这一章要解决的是在它上面做验证与迭代。
原型要回答的不是「能不能做」而是「值不值得做」
第 6 章的原型证明了「核心循环跑得起来」。但「跑得起来」和「值得往下做」是两回事。一个能跑的原型可以毫无乐趣:玩家点两下就退出、单局短到没有节奏、没人愿意再来一局。原型的价值不在于演示,而在于用最小成本证伪一个假设。
所以原型的第一件事不是写代码,而是把范围钉死。范围失控是原型最常见的死法:一开始说「就验证合成手感」,三天后变成「顺便把登录、排行榜、内购也搭一下吧」——最后原型变成了半个产品,却一个问题都没回答。一个没有边界的原型,等于一个没有结论的实验。
要把范围钉死,只需要回答三个问题:
- 做什么:这一次原型要覆盖的最小能力集合是什么?
- 不做什么:哪些东西明确不碰,即使它们看起来顺手?
- 每个验证目标对应什么指标:每个目标必须绑定一个可测的数字,否则它就不是目标,是愿望。
第三点最容易被跳过,也最致命。如果目标是「验证玩法有趣」,那它永远无法被证伪,因为没有哪个数字能定义「有趣」。可测的写法是「首局完成率 ≥ 500 千分」「单局时长落在 90–240 秒」。指标是把假设变成可判定命题的那一步。
D 层:原型范围与验证目标表
下面这张表是本章的「一份 config」——落在 config/ch12_prototype.json。它把上面三个问题固化成机器可读的结构:inScope / outOfScope 是边界,metrics 是指标注册表(每个指标声明单位与来源),goals 是验证目标,每个目标用 metric 字段绑定到注册表里的一个指标。注意全部是整数:占比用千分(per-mille),时长用秒,没有任何浮点。
{
"version": 1,
"game": "merge-island",
"prototype": "merge-core-0.1",
"goal": "用最小代码验证「拖拽合成 + 产出金币 + 再投入升级」的核心循环是否成立,不验证完整游戏。",
"inScope": [
"合成内核:产出 / 拖拽合成 / 等级上限 / 金币奖励",
"一次可玩会话:从空棋盘到第一局结束",
"单机可播种随机:同一 seed 可逐位回放",
"一个浏览器可打开的单文件页面 + 无头自动验证"
],
"outOfScope": [
"账号、云存档与跨端同步",
"内购与广告 SDK 接入",
"社交、好友与排行榜",
"美术与音效成品资源",
"多关卡、关卡编辑器与关卡数值"
],
"metrics": [
{ "id": "firstRunCompletionPerMille", "unit": "per-mille", "source": "playtest", "desc": "完成第一局的测试者占比(千分)" },
{ "id": "meanSessionSec", "unit": "second", "source": "playtest", "desc": "一次会话的平均时长(秒)" },
{ "id": "replayPerMille", "unit": "per-mille", "source": "playtest", "desc": "至少重开一局的测试者占比(千分)" },
{ "id": "softlockCount", "unit": "count", "source": "replay", "desc": "确定性回放中出现的死锁次数" }
],
"goals": [
{ "id": "g1", "name": "核心循环可理解", "metric": "firstRunCompletionPerMille", "direction": "up", "threshold": 500, "northStar": true },
{ "id": "g2", "name": "单局时长落在心流区间", "metric": "meanSessionSec", "direction": "range", "threshold": 0, "lower": 90, "upper": 240, "northStar": false },
{ "id": "g3", "name": "愿意再来一局", "metric": "replayPerMille", "direction": "up", "threshold": 400, "northStar": false },
{ "id": "g4", "name": "回放无死锁", "metric": "softlockCount", "direction": "down", "threshold": 0, "northStar": false }
]
}
这张表里有几个刻意的设计。第一,goals 里的每个目标都通过 metric 指向注册表,而不是内联一个字符串——这样「目标引用的指标必须真实存在」就是一条可校验的约束。第二,每个目标带 direction:up 表示越大越好(完成率、重玩率),down 表示越小越好(死锁次数),range 表示落在区间内(单局时长)。第三,g1 被标为 northStar(北极星)——它是这一版原型唯一不能失败的目标:如果连「核心循环可理解」都不成立,后面所有微调都没有意义。北极星是止损线的判据,这一点在 12.3 会变成可执行的代码。
S 层:范围门禁脚本
配置写好了,怎么保证它不会悄悄退化?答案是门禁脚本。下面这段是 S 层脚本,读 config/ch12_prototype.json,逐条断言「每个验证目标都有可测指标」。它的核心检查有五条:边界非空且互不相交、每个目标字段齐全、每个目标的指标必须在注册表里且注册表声明了单位与来源、北极星恰好一个、range 型目标的上下界必须有序。任何一条不满足即非零退出。
# file: sim/ch12_scope_guard.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch12_scope_guard.py --config config/ --out out/
"""原型范围与验证目标门禁:断言「每个验证目标都有可测指标」。
规则(任一违反即非零退出):
1. inScope / outOfScope 均非空,且两者不相交;
2. 每个 goal 必须有 id / name / metric / direction / threshold;
3. 每个 goal 的 metric 必须在 metrics 注册表里,注册表声明了 unit 与 source;
4. 有且只有一个 northStar;
5. direction=range 的 goal 必须 lower < upper。
通过则产出 out/ch12_scope.json + out/ch12_scope.csv + out/ch12_scope.svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
REQUIRED_GOAL = ("id", "name", "metric", "direction", "threshold")
def load(cfg_dir: str) -> dict:
with open(os.path.join(cfg_dir, "ch12_prototype.json"), encoding="utf-8") as f:
return json.load(f)
def check(cfg: dict) -> list[str]:
errs: list[str] = []
scope = cfg.get("inScope", [])
out = cfg.get("outOfScope", [])
if not scope:
errs.append("inScope 为空:原型必须至少声明一项做什么")
if not out:
errs.append("outOfScope 为空:原型必须至少声明一项不做什么")
overlap = sorted(set(scope) & set(out))
if overlap:
errs.append(f"做什么与不做什么相交:{overlap}")
registry = {m["id"]: m for m in cfg.get("metrics", [])}
if not registry:
errs.append("metrics 注册表为空:没有任何可测指标")
goals = cfg.get("goals", [])
if not goals:
errs.append("goals 为空:原型必须至少有一个验证目标")
north = 0
for g in goals:
for key in REQUIRED_GOAL:
if key not in g:
errs.append(f"目标 {g.get('id', '?')} 缺字段 {key}")
mid = g.get("metric")
if mid not in registry:
errs.append(f"目标 {g.get('id', '?')} 的指标 {mid} 不在注册表里(不可测)")
else:
if not registry[mid].get("unit"):
errs.append(f"指标 {mid} 未声明 unit")
if not registry[mid].get("source"):
errs.append(f"指标 {mid} 未声明 source")
if g.get("direction") == "range":
lo, hi = g.get("lower", 0), g.get("upper", 0)
if not (lo < hi):
errs.append(f"目标 {g.get('id', '?')} 是 range 但 lower({lo}) 不小于 upper({hi})")
if g.get("northStar") is True:
north += 1
if north != 1:
errs.append(f"northStar 目标应恰好一个,实为 {north}")
return errs
def draw_svg(path: str, scope: list[str], out: list[str]) -> int:
W = 760
row_h = 26
top = 64
H = top + row_h * max(len(scope), len(out)) + 28
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": "20", "y": "30", "font-size": "16", "fill": "#111827"}).text = \
"原型范围:做什么(左) / 不做什么(右)"
col_x = (20, 400)
heads = ("做什么 inScope", "不做什么 outOfScope")
for x, h in zip(col_x, heads):
ET.SubElement(svg, "text", {"x": str(x), "y": "54", "font-size": "13",
"font-weight": "bold", "fill": "#2563eb"}).text = h
for col, items in enumerate((scope, out)):
for i, item in enumerate(items):
y = top + row_h * i
ET.SubElement(svg, "text", {"x": str(col_x[col]), "y": str(y + 12), "font-size": "12",
"fill": "#374151"}).text = f"{i + 1}. {item}"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
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)
errs = check(cfg)
if errs:
for e in errs:
print(f"FAIL {e}")
print(f"== 原型范围门禁 FAIL {len(errs)}")
return 1
registry = {m["id"]: m for m in cfg["metrics"]}
rows = []
for g in cfg["goals"]:
m = registry[g["metric"]]
rows.append({
"goal": g["id"], "name": g["name"], "metric": g["metric"],
"direction": g["direction"], "threshold": g["threshold"],
"unit": m["unit"], "source": m["source"], "northStar": bool(g.get("northStar")),
})
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch12_scope.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
svg_bytes = draw_svg(os.path.join(args.out, "ch12_scope.svg"), cfg["inScope"], cfg["outOfScope"])
report = {
"version": cfg["version"],
"prototype": cfg["prototype"],
"inScope": len(cfg["inScope"]),
"outOfScope": len(cfg["outOfScope"]),
"metrics": len(registry),
"goals": rows,
"northStar": next(r["goal"] for r in rows if r["northStar"]),
"allGoalsMeasurable": True,
"svg": os.path.join(args.out, "ch12_scope.svg"),
"svgBytes": svg_bytes,
}
with open(os.path.join(args.out, "ch12_scope.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"inScope": report["inScope"], "outOfScope": report["outOfScope"],
"metrics": report["metrics"], "goals": len(rows),
"northStar": report["northStar"]}, ensure_ascii=False))
print("== 原型范围门禁 PASS:每个验证目标都有可测指标")
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑 /opt/homebrew/bin/python3 sim/ch12_scope_guard.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):
{"inScope": 4, "outOfScope": 5, "metrics": 4, "goals": 4, "northStar": "g1"}
== 原型范围门禁 PASS:每个验证目标都有可测指标
门禁通过,说明四项目标都绑定了注册表里的可测指标、北极星恰好一个、range 型目标的上下界有序。脚本同时产出 out/ch12_scope.csv(目标清单)和 out/ch12_scope.svg(做什么 / 不做什么的两栏图)。这份 CSV 就是后面 12.3 迭代决策要消费的目标表——同一份目标定义,从范围门禁一路用到决策内核。
范围门禁拦住的三类跑偏
第一类是边界相交。有人会在 inScope 写「合成内核」,又在 outOfScope 写「合成特效」——听着像不冲突,但「合成内核」如果不含特效,那特效该谁做?门禁用集合交集把这类含糊直接顶出来,逼你重新切分。
第二类是指标不可测。目标是「验证合成爽感」,metric 写了个注册表里没有的 feelScore。门禁会报「指标不在注册表里」。这条约束的意义是:不能凭空发明一个指标名,必须先在注册表里定义它的单位与来源。一个没有单位、没有来源的指标,和「有趣」一样不可证伪。
第三类是北极星缺位或泛滥。一个都没有,说明你没想清楚哪条是底线;冒出三个,说明你什么都想要,等于没有优先级。门禁强制「恰好一个」,把优先级这件事变成一条硬约束。
把范围钉死之后,下一节要解决的是:这些指标到底怎么测、测出来的数字有多大把握。范围门禁保证「测什么」是清楚的,12.2 保证「怎么测」是可复现的。
阅读导航:上一节:立项决策与止损线 · 下一节:可玩性指标与测试方法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。