11.1 一页纸设计与立项文档

把立项决策压缩成一张可校验的一页纸:用配置驱动《合成小岛》的十个必答问题,覆盖做什么、做多少、凭什么停三段,再用 S 层脚本逐项断言必填项是否齐全、答案字数是否达标,产出 CSV 与完整度条形图,并讲清一页纸与详细设计案的分工边界,附可复用的自检表。

站内已有一组做小游戏服务平台的内容(小游戏服务平台专题 ),那 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 万元」来自下一节的预算估算模型,而「次日留存达到预设下限」里的「下限」是一个待定的参数——参数可以先留空,但位置必须留着。留空的位置提醒你「这里还有一个没定的数」,而一个被删掉的位置,会在三个月后变成一次痛苦的返工。

下一节给这份一页纸里最难的两项——「预算周期」和「首版范围」——装上可运行的估算器:把里程碑拆开、把人力工时折算成钱,用整数递推算出一张排期甘特图。

阅读导航:上一节:变现与体验的平衡 · 下一节:开发预算与排期估算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制