站内已有一组做小游戏服务平台的内容(小游戏服务平台专题 ),那 25 篇讲的是「做一个供别人开发小游戏的服务平台」——BP 怎么写、融资口径怎么定、编辑器与后台怎么做。本章的视角完全不同:我们不再做平台,而是做一款具体的小游戏,关心的是单款小游戏的立项决策与止损线——这一版做不做、做多久、花多少、什么信号出现就该停。同一批读者可能两边都看,但要清楚这两件事的决策对象根本不同:平台看的是生态与规模,单款产品看的是这一款能不能在有限的投入内跑通。
前面十章,我们把外部判断做完了:市场、榜单、品类、平台约束、用户、玩法、关卡、成长、社交、变现。这些是分散的结论——散落在十份配置、十份仿真输出里。这一章的任务,是把它们收进一张纸。因为评审席上没人会读十份配置,他们只会读一页。
立项文档要解决什么问题
一个项目最容易死在哪里?不是技术做不出来,也不是玩法不好玩,而是没人能一句话说清「我们到底在做什么、做到什么程度算成」。设计案写了两百页,评审会上却各说各话:策划以为要做出「合成 + 放置 + 社交」的完整体验,程序以为首版只做单机合成,老板以为三个月能上线。三方对同一个项目的想象完全不同,却都以为自己理解了。
一页纸要解决的,就是这件事。它的目的不是把设计讲清楚(那是详细设计案的活),而是把决策讲清楚:做什么、不做什么、花多少、凭什么判断成不成。一份合格的一页纸,应该让一个没参与过讨论的人,在五分钟内回答出下面三个问题:
- 这个游戏一句话是什么?
- 首版明确不做什么?
- 上线后用什么数字判断成不成、什么时候该停?
如果这页纸做不到,说明项目还没想清楚,不该进入开发。这不是流程洁癖,而是把「想清楚」的成本提前到最便宜的阶段——在写第一行代码之前。
一页纸的十个必答问题
市面上的立项模板五花八门,但真正必须回答的问题其实不多。本套把《合成小岛》的一页纸压缩成十个必答项,每一项对应一个具体的决策,缺一项就是一个没想清楚的地方。
下面这份配置是 **D 层(静态层)**产物:requiredFields 定义一份一页纸必须回答的问题,answers 是《合成小岛》的实际填写。它本身不产生数字,但把「一页纸」从一个主观的文档格式,固化成一张可被脚本读取、可被逐项断言的表。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "《合成小岛》一页纸立项文档:requiredFields 定义一份可评审的一页纸必须回答的问题;answers 是《合成小岛》的实际填写。校验脚本 sim/ch11_onepager_check.py 断言必填项齐全、答案非空、且关键项达到最小字数。全部为整数与字符串,无浮点。",
"minAnswerChars": 8,
"requiredFields": [
{ "id": "oneLiner", "label": "一句话玩法", "hint": "动作+资源+反馈+再投入,能被检验" },
{ "id": "genre", "label": "品类与参照", "hint": "品类归属 + 3 款参照产品" },
{ "id": "target", "label": "目标用户", "hint": "画像 + 场景,一句话说清给谁做" },
{ "id": "coreLoop", "label": "核心循环", "hint": "最短一圈是哪几步、大约几秒" },
{ "id": "monetization", "label": "变现方式", "hint": "广告 / 内购 / 混合,先想清再动手" },
{ "id": "scope", "label": "首版范围", "hint": "首版做什么、明确不做什么" },
{ "id": "budget", "label": "预算与周期", "hint": "人力 × 工时 × 单价,给出上限" },
{ "id": "metric", "label": "成功判据", "hint": "上线后用什么数字判断成不成" },
{ "id": "stopLoss", "label": "止损条件", "hint": "什么信号出现就该停,先写下来" },
{ "id": "risk", "label": "最大风险", "hint": "最可能让项目失败的一件事" }
],
"answers": {
"oneLiner": "拖拽两个相同物品合成高一级物品,用产出金币升级岛屿、解锁新地块,换来更高级物品与更快的产出。",
"genre": "合成放置类,参照《合成大西瓜》的合并爽感、《旅行青蛙》的放置节奏与《开心水族箱》的装饰收集。",
"target": "25-40 岁、单次游玩 3-8 分钟的休闲玩家,偏好无需硬操作、能随时中断的碎片时间游戏。",
"coreLoop": "点按产出器出物品、拖拽合成升阶、交付出售换金币三步构成最短一圈,约 5 秒。",
"monetization": "以激励视频为主(加速产出 / 翻倍出售 / 补发离线收益),内购只做去广告与装饰礼包,不设强付费墙。",
"scope": "首版只做一张岛、8 级物品、3 类产出器与基础装扮;多人协作、赛季、公会明确不做。",
"budget": "示例假设:首版约 188 个人日、约 78 个工作日,按每日单价折算总预算上限约 34.8 万元,超出即触发复核。",
"metric": "上线首两周看次日留存是否达到预设下限、单用户广告展示次数是否落在区间内,二者同时达标才算过。",
"stopLoss": "若核心原型阶段结束时,可玩性测试中的「愿意再玩一次」比例低于预设阈值,则暂停后续投入并回到玩法重做。",
"risk": "最大风险是合成手感做不出来——合并的吸附与光效若不到位,整个玩法失去爽点,留存无从谈起。"
}
}
十项里,前四项(一句话玩法、品类、目标用户、核心循环)是**「做什么」,中间三项(变现、首版范围、预算周期)是「做多少」,最后三项(成功判据、止损条件、最大风险)是「凭什么停」**。注意最后三项——多数团队的一页纸会写前七项,然后漏掉后三项。而恰恰是后三项,决定了项目在坏消息来临时是继续砸钱还是及时收手。
用 S 层脚本校验一页纸是否齐全
写出来不等于写全了。一页纸最常见的缺陷不是「写错」,而是「漏项」——尤其是那些填起来最难的项(成功判据、止损条件),最容易被悄悄跳过。所以这份一页纸要能被自动校验:逐项断言必填项是否存在、答案是否非空、是否达到最小字数。
下面这段是 S 层仿真脚本,读 config/ 里的一页纸配置,对每个必答项检查答案字数,算出「完整度千分比」,并画出完整度条形图。
#!/usr/bin/env python3
# file: sim/ch11_onepager_check.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch11_onepager_check.py --config config/ --out out/
"""一页纸立项文档校验:读 config/ch11_onepager.json,断言必填项齐全。
对 requiredFields 里每一项,检查 answers 是否有对应键、答案是否非空、
是否达到 minAnswerChars 的最小字数;再统计「一页纸完整度」千分比。
产出:out/ch11_onepager.json + out/ch11_onepager_fields.csv + out/ch11_onepager.svg
全整数、无浮点:完整度用整数地板除。
"""
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, "ch11_onepager.json"), encoding="utf-8") as f:
return json.load(f)
def permille(part: int, whole: int) -> int:
"""整数千分比(地板除),与 TS 侧 Math.floor(part*1000/whole) 等价。"""
return (part * 1000) // whole if whole else 0
def draw_svg(path: str, rows: list[dict], complete: int, total: int) -> int:
"""纯 Python SVG:每项一根横条,长度正比于答案字数(截顶到 minChars 的 3 倍)。"""
W, PAD, ROW = 760, 200, 40
H = 70 + ROW * len(rows)
cap = 240
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": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
f"《合成小岛》一页纸完整度:{complete}/{total} 项({permille(complete, total)}‰)"
)
bar_max = W - PAD - 90
for i, r in enumerate(rows):
y = 50 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = r["label"]
w = (bar_max * min(r["chars"], cap)) // cap
color = "#2563eb" if r["ok"] else "#dc2626"
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(max(w, 2)), "height": "20",
"rx": "4", "fill": color})
mark = "OK" if r["ok"] else "缺"
ET.SubElement(svg, "text", {"x": str(PAD + max(w, 2) + 8), "y": str(y + 21), "font-size": "12",
"fill": "#6b7280"}).text = f'{r["chars"]}字 · {mark}'
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)
min_chars = cfg["minAnswerChars"]
answers = cfg["answers"]
rows, missing = [], []
for f in cfg["requiredFields"]:
ans = answers.get(f["id"], "")
chars = len(ans)
ok = chars >= min_chars
rows.append({"id": f["id"], "label": f["label"], "chars": chars, "ok": ok})
if not ok:
missing.append(f["id"])
total = len(rows)
complete = sum(1 for r in rows if r["ok"])
report = {
"version": cfg["version"],
"game": cfg["game"],
"seed": cfg["seed"],
"minAnswerChars": min_chars,
"requiredCount": total,
"filledCount": complete,
"completePermille": permille(complete, total),
"missing": missing,
"fields": rows,
}
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "ch11_onepager.svg"), rows, complete, total)
report["svg"] = os.path.join(args.out, "ch11_onepager.svg")
report["svgBytes"] = svg_bytes
with open(os.path.join(args.out, "ch11_onepager.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch11_onepager_fields.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "label", "chars", "ok"])
for r in rows:
w.writerow([r["id"], r["label"], r["chars"], "1" if r["ok"] else "0"])
print(json.dumps({"requiredCount": total, "filledCount": complete,
"completePermille": report["completePermille"],
"missing": missing}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"requiredCount": 10, "filledCount": 10, "completePermille": 1000, "missing": []}
completePermille = 1000 表示十项全填齐。同时落盘 out/ch11_onepager_fields.csv(每项一行)与 out/ch11_onepager.svg(完整度条形图)。CSV 是这样:
id,label,chars,ok
oneLiner,一句话玩法,46,1
genre,品类与参照,46,1
target,目标用户,46,1
coreLoop,核心循环,38,1
monetization,变现方式,50,1
scope,首版范围,41,1
budget,预算与周期,49,1
metric,成功判据,46,1
stopLoss,止损条件,50,1
risk,最大风险,44,1
完整度读出来的三个结论
数字出来了,1000‰ 是「满分」,但满分不代表没问题。读这份 CSV,注意三件事。
第一,字数只是下限,不是质量。 脚本检查的是「每项至少 8 个字」,这个门槛很低——它只挡住「未填」和「一句话敷衍」,挡不住「填了但没想清楚」。coreLoop 只有 38 字,是十项里最短的,但它是信息密度最高的:它点出了最短一圈的三步和大约 5 秒。反过来,monetization 有 50 字,写了「激励视频为主 + 内购只做去包与装饰」,也算具体。所以脚本给的是完整性下限,真正的质量还得靠评审。
第二,脚本的价值在于把「漏项」变成红灯。 把 answers 里任意一项删掉,重跑脚本,missing 里就会冒出那个 id,completePermille 从 1000 掉到 900。这意味着「一页纸写全了没有」这件事,从口头确认变成了可复现的断言。团队里任何一个人都能在提交前跑一次,而不是等评审会上被人当场问住。
第三,minAnswerChars 本身是个可调参数。 本套设成 8,是因为这是「非敷衍」的最低门槛。如果你希望更严格,可以把它调到 30——那样 coreLoop(38 字)刚好过线,而任何低于 30 字的项都会被标红。参数放在配置里,就意味着门槛可以随项目阶段收紧:立项初期宽一点,进入开发前必须收紧。
一页纸与详细设计案的分工
一个常见的误解是「一页纸是设计案的缩略版」。不是。两者回答的是不同层级的问题,服务的是不同的读者。
| 维度 | 一页纸 | 详细设计案 |
|---|---|---|
| 回答的问题 | 做不做、做多少、凭什么停 | 具体怎么做 |
| 读者 | 评审席 / 决策者 / 全组 | 执行开发的人 |
| 篇幅 | 一页 | 几十到几百页 |
| 更新频率 | 立项时定,重大转向才改 | 随开发持续迭代 |
| 生命周期 | 贯穿项目始终 | 交付后归档 |
| 本章 / 后续 | 本章(11.1) | 第 15 章(15.2 设计案装配) |
一页纸在上游:它决定项目要不要启动、首版范围划在哪。详细设计案在下游:它把一页纸里那句「首版做一张岛、8 级物品」展开成物品表、关卡表、UI 稿、接口定义。一页纸说「做什么」,设计案说「怎么做到」。顺序不能颠倒——先写几百页设计案再回头补一页纸,通常意味着前几百页里有一半会被砍掉。
这也是本章和后续章节的边界:本章只负责把决策压到一页,不展开任何系统细节;系统怎么拆、界面怎么画、数据表怎么定,属于第 15 章的设计案装配。中间的第 12 到 14 章,则分别补上一页纸里「首版范围」背后的原型验证、「预算周期」背后的资源计划。
一页纸的自检表
把上面的经验收成一张表。写完一页纸后逐条对照,任何一条答不上来,就还没到立项的标准。
| 检查项 | 合格标准 | 常见缺陷 |
|---|---|---|
| 一句话玩法 | 含动作/资源/反馈/再投入 | 写成宣传语,无法检验 |
| 首版范围 | 明确列出「不做什么」 | 只写做什么,不写不做什么 |
| 预算周期 | 给出上限,不是区间中值 | 写「大概几个月」 |
| 成功判据 | 落到具体数字与时间窗 | 写「用户喜欢就行」 |
| 止损条件 | 写明「什么信号出现就停」 | 整项缺失 |
| 最大风险 | 指名一件最可能失败的事 | 写「市场竞争激烈」 |
| 完整度 | 脚本 missing 为空 | 靠人肉确认,必漏项 |
这张表里最容易被跳过的,是「止损条件」和「最大风险」。它们难写,因为写下来就意味着承诺——承诺在某个信号出现时真的停手。而多数团队的真实心态是「先做着看」。但恰恰是这两个字段,把一个「感觉能做」的冲动,变成了一个「有边界」的决策。这一章的最后一节(11.3)会把止损线做成一整套可调的模型,这里先在纸面上把它钉死。
还有一点要提醒:一页纸里的所有数字都必须是示例假设或可验证的判据,不能是拍脑袋的承诺。比如「约 34.8 万元」来自下一节的预算估算模型,而「次日留存达到预设下限」里的「下限」是一个待定的参数——参数可以先留空,但位置必须留着。留空的位置提醒你「这里还有一个没定的数」,而一个被删掉的位置,会在三个月后变成一次痛苦的返工。
下一节给这份一页纸里最难的两项——「预算周期」和「首版范围」——装上可运行的估算器:把里程碑拆开、把人力工时折算成钱,用整数递推算出一张排期甘特图。
阅读导航:上一节:变现与体验的平衡 · 下一节:开发预算与排期估算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。