2.3 从竞品到差异化机会

拆完竞品,下一步是找位置。本篇把「空白度 × 竞争度 × 变现力」做成可加权的机会矩阵,用整数算术给每个候选机会打分排序,再用 B 基准遍历权重网格,把机会分成「稳健」与「权重敏感」,产出可重跑的 JSON、CSV 与 SVG,全程不碰任何真实市场调研数据。

前两节把市场读成了结构、把竞品量成了坐标,这一节要把两者收成一句话:我们打算在哪个位置、做一件别人没占住的事。这就是差异化机会。照例先说分工:/h5游戏产业全景/ 那篇是 H5 小游戏的产业综述,讲格局与趋势;本章给的是可量化的榜单结构分析与机会评估——把「哪里有位置」从一句直觉,变成一张可打分、可排序、可做敏感性检验的表。/posts/products/ 下的 PRD 系列是产品需求文档,回答「要做成什么样」;本章给的是可跑的数据分析与机会评估,回答「值不值得往那个方向做」。PRD 是终点,本章是决策前的输入。

从「别人做了什么」到「哪里有位置」

2.2 的竞品表回答的是「别人做到了什么程度」,但一张竞品表本身不告诉你该往哪走。差异化机会要同时满足三个条件,缺一不可:

  1. 有空白——这个方向还没被头部占满,玩家需求没被充分满足。
  2. 竞争可控——不是最挤的赛道,进入后还有生存与获客空间。
  3. 能变现——这个方向的玩家愿意付费、或者广告效率够高。

三者常常互相矛盾:最空白的往往变现弱,最赚钱的往往最挤。所以不能靠单一指标排序,必须把它们放进一个加权评分里权衡——而权重本身,又必须经得起推敲。这就是本节要建的两样东西:机会矩阵(S 层)与权重敏感性基准(B 层)。

机会矩阵的三维定义

把上面三条件翻译成可打分的三个维度(0~100 整数分):

维度含义方向打分依据(公开可观察 / 示例)
空白度 gap该细分被满足的程度越高越空白榜上同类数量、玩法同质化程度
竞争度 competition该细分的拥挤程度越高越挤头部集中度、买量激烈程度
变现力 monetization该细分的变现效率越高越好付费深度、广告形态契合度

评分时,竞争度是反向项——越挤分越低,所以机会分里用 (100 - competition) 作惩罚项。候选机会本身是「品类 × 机制」的组合,比如「合成 + 装扮」「塔防 + 合成」——注意这里只是列出候选并打分,不决定做哪个(那是第 3 章的活)。

下面这段是 D 层配置:一份机会矩阵,含三维定义与权重,以及 8 个候选机会的示例评分,另附 B 层要用的权重敏感性网格。

{
  "version": 1,
  "observation": "示例数据(非真实市场调研)",
  "disclaimer": "本表的候选机会与三个评分均为作者构造的示例,用于演示「空白度 × 竞争度 × 变现力」加权评分与权重敏感性分析的算法,不代表对任何真实细分市场的调研结论。真实市场规模与竞争强度需真实业务环境,命令行不可复现(X)。",
  "dimensions": [
    { "id": "gap", "name": "空白度", "weight": 40, "note": "越高越空白,越有差异化空间" },
    { "id": "monetization", "name": "变现力", "weight": 40, "note": "越高越容易变现" },
    { "id": "competition", "name": "竞争度", "weight": 20, "note": "越高越拥挤,评分时取 (100 - competition) 作惩罚项" }
  ],
  "opportunities": [
    { "id": "merge-dressup", "name": "合成 + 装扮", "gap": 70, "competition": 45, "monetization": 75 },
    { "id": "merge-idle", "name": "合成 + 放置", "gap": 55, "competition": 70, "monetization": 80 },
    { "id": "merge-level", "name": "合成 + 关卡", "gap": 50, "competition": 75, "monetization": 78 },
    { "id": "match3-decorate", "name": "消除 + 装修", "gap": 30, "competition": 90, "monetization": 88 },
    { "id": "puzzle-narrative", "name": "益智 + 叙事", "gap": 65, "competition": 40, "monetization": 55 },
    { "id": "sim-merge", "name": "模拟经营 + 合成", "gap": 60, "competition": 55, "monetization": 70 },
    { "id": "tower-merge", "name": "塔防 + 合成", "gap": 72, "competition": 35, "monetization": 60 },
    { "id": "idle-collect", "name": "放置 + 收集", "gap": 40, "competition": 85, "monetization": 82 }
  ],
  "sensitivityGrid": {
    "gap": [20, 30, 40, 50],
    "monetization": [20, 30, 40, 50],
    "competitionMin": 10,
    "competitionMax": 30,
    "topK": 3
  }
}

三个维度分都是示例评分,disclaimer 把「非真实调研」写死在文件里。权重的选择本身也是产品判断——这里给空白度与变现力各 40、竞争度 20,因为对一个小团队来说「有位置」和「能赚钱」比「挤不挤」更重要。这个权重凭什么? 这正是 B 层要回答的问题:如果换个权重结论就翻盘,那这个结论就不牢靠。

加权评分(S 层)

下面这段是 S 层仿真脚本,读 config/ch02_opportunity.json,按权重把三维评分加权成 0~100 的机会分并排序,产出 JSON + CSV + 一个纯 Python SVG 排序条形图。全程整数地板除:机会分 = (wGapgap + wMonetmonetization + wComp(100-competition)) // 100*。

# file: sim/ch02_opportunity_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch02_opportunity_matrix.py --config config/ --out out/
"""差异化机会矩阵(示例数据口径):读 config/ch02_opportunity.json,把每个候选机会在
「空白度 / 竞争度 / 变现力」三维上按权重加权成 0~100 的机会分,产出 JSON + CSV + SVG。

机会分 = (wGap*gap + wMonet*monetization + wComp*(100-competition)) // 100,全程整数地板除。
纪律:输入是示例评分,不是真实市场调研。真实细分市场规模与竞争强度属 X,命令行不可复现。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load_config(path: str) -> dict:
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def weights(cfg: dict) -> dict:
    return {d["id"]: d["weight"] for d in cfg["dimensions"]}


def opportunity_score(opp: dict, w: dict) -> int:
    """三维加权机会分(0~100 整数):竞争度取 (100 - competition) 作惩罚项。"""
    return (w["gap"] * opp["gap"] + w["monetization"] * opp["monetization"]
            + w["competition"] * (100 - opp["competition"])) // 100


def rank(cfg: dict) -> list[dict]:
    w = weights(cfg)
    rows = []
    for o in cfg["opportunities"]:
        rows.append({"id": o["id"], "name": o["name"], "gap": o["gap"],
                     "competition": o["competition"], "monetization": o["monetization"],
                     "score": opportunity_score(o, w)})
    rows.sort(key=lambda r: (-r["score"], r["id"]))
    return rows


def write_csv(path: str, rows: list[dict]) -> None:
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "gap", "competition", "monetization", "score"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["gap"], r["competition"], r["monetization"], r["score"]])


def write_svg(path: str, rows: list[dict]) -> None:
    left, top, bar_h, gap = 140, 56, 30, 40
    width, height = left + 420, top + gap * len(rows) + 24
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
                             "height": str(height), "viewBox": f"0 0 {width} {height}"})
    ET.SubElement(svg, "text", {"x": str(left), "y": "30", "font-size": "16",
                                "font-family": "sans-serif"}).text = "差异化机会分排序(示例数据,0~100)"
    for i, r in enumerate(rows):
        y = top + i * gap
        ET.SubElement(svg, "text", {"x": str(left - 10), "y": str(y + 20), "font-size": "12",
                                    "text-anchor": "end", "font-family": "sans-serif"}).text = r["name"]
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(int(3.4 * r["score"])),
                                    "height": str(bar_h), "fill": "#7c3aed"})
        ET.SubElement(svg, "text", {"x": str(left + 348), "y": str(y + 20), "font-size": "12",
                                    "font-family": "sans-serif"}).text = f"{r['score']}"
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load_config(os.path.join(args.config, "ch02_opportunity.json"))
    os.makedirs(args.out, exist_ok=True)
    rows = rank(cfg)
    write_csv(os.path.join(args.out, "ch02_opportunity.csv"), rows)
    write_svg(os.path.join(args.out, "ch02_opportunity.svg"), rows)

    report = {"source": "sim/ch02_opportunity_matrix.py", "observation": cfg["observation"],
              "disclaimer": cfg["disclaimer"], "weights": weights(cfg), "ranking": rows,
              "top1": rows[0]["id"], "artifacts": ["out/ch02_opportunity.csv", "out/ch02_opportunity.svg"]}
    with open(os.path.join(args.out, "ch02_opportunity.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"top1": report["top1"], "ranking": rows}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

同机实跑(8 个候选机会,节选输出):

top1: merge-dressup(合成 + 装扮)  机会分=69
ranking(机会分降序):
  合成 + 装扮       gap=70 competition=45 monetization=75  69
  塔防 + 合成       gap=72 competition=35 monetization=60  65
  模拟经营 + 合成   gap=60 competition=55 monetization=70  61
  合成 + 放置       gap=55 competition=70 monetization=80  60
  益智 + 叙事       gap=65 competition=40 monetization=55  60
  合成 + 关卡       gap=50 competition=75 monetization=78  56
  放置 + 收集       gap=40 competition=85 monetization=82  51
  消除 + 装修       gap=30 competition=90 monetization=88  49
产物: out/ch02_opportunity.csv  out/ch02_opportunity.svg

读法:「合成 + 装扮」以 69 分居首——空白度不低(70)、竞争可控(45)、变现力中上(75),三项都均衡。「消除 + 装修」变现力最高(88),但因为空白度只有 30、竞争度高达 90,被惩罚项拉到了垫底——这就是「最赚钱的往往最挤」的量化体现。如果只看变现力排序,会得出完全相反的结论。

B 基准:评分权重敏感性

单靠一组权重排出来的名次,最容易被质疑的一句话是:「你换个权重试试?」B 层基准就是来回答这句话的。下面这段是 B 层基准脚本:把三个权重放进网格遍历(竞争度权重由 100 - 前两者 推出并限定区间),每个组合重算一次排序,统计每个机会进 Top-K 的频次与首位频次,把机会分成「稳健」与「权重敏感」。脚本永远 exit 0,只出软告警,不判决对错。

# file: sim/ch02_opportunity_sensitivity.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch02_opportunity_sensitivity.py --config config/ --out out/
"""B 基准:机会矩阵的「权重敏感性」。

把空白度 / 变现力 / 竞争度的权重在网格上遍历(竞争度权重由 100 - 前两者推出,限定在区间内),
每个权重组合重算一次机会分排序,统计每个候选机会进入 Top-K 的频次与首位频次,
据此把机会分成「稳健」(几乎所有权重组合都进 Top-K)与「敏感」(进不进 Top-K 取决于权重)。

软告警不硬失败:脚本永远 exit 0,只把权重敏感的机会列进 warnings 供决策,不作对错判决。
产出 out/ch02_opportunity_sensitivity.json。同机相对量级,绝对数值不可移植。
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch02_opportunity_matrix import load_config, opportunity_score  # noqa: E402


def grid_combos(g: dict) -> list[dict]:
    combos = []
    for wg in g["gap"]:
        for wm in g["monetization"]:
            wc = 100 - wg - wm
            if g["competitionMin"] <= wc <= g["competitionMax"]:
                combos.append({"gap": wg, "monetization": wm, "competition": wc})
    return combos


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load_config(os.path.join(args.config, "ch02_opportunity.json"))
    g = cfg["sensitivityGrid"]
    top_k = g["topK"]
    combos = grid_combos(g)
    os.makedirs(args.out, exist_ok=True)

    tally: dict[str, dict] = {o["id"]: {"name": o["name"], "topK": 0, "first": 0} for o in cfg["opportunities"]}
    n = len(combos)
    for combo in combos:
        scored = sorted(((o["id"], opportunity_score(o, combo)) for o in cfg["opportunities"]),
                        key=lambda t: (-t[1], t[0]))
        for rank, (oid, _score) in enumerate(scored[:top_k]):
            tally[oid]["topK"] += 1
            if rank == 0:
                tally[oid]["first"] += 1

    rows = []
    for oid, t in tally.items():
        rows.append({"id": oid, "name": t["name"],
                     "topKRateBp": t["topK"] * 10000 // n,
                     "firstRateBp": t["first"] * 10000 // n,
                     "topKCount": t["topK"], "firstCount": t["first"]})
    rows.sort(key=lambda r: (-r["topKRateBp"], -r["firstRateBp"], r["id"]))

    stable = [r["id"] for r in rows if r["topKRateBp"] >= 9000]
    sensitive = [r["id"] for r in rows if 0 < r["topKRateBp"] < 9000]
    never = [r["id"] for r in rows if r["topKRateBp"] == 0]

    report = {"source": "sim/ch02_opportunity_sensitivity.py", "kind": "benchmark", "severity": "soft",
              "note": "同机相对量级,绝对数值不可移植;这是权重敏感性对照,不是对错判决。",
              "gridCombos": n, "topK": top_k, "rows": rows,
              "stable": stable, "sensitive": sensitive, "neverTopK": never,
              "softWarnings": sensitive}
    with open(os.path.join(args.out, "ch02_opportunity_sensitivity.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    sys.exit(0)  # B 层软告警:永远成功退出


if __name__ == "__main__":
    main()

同机实跑(9 个权重组合,节选输出):

gridCombos: 9   topK: 3
stable(进 Top-3 率 >= 90%):    合成 + 装扮(topK 9/9, 首位 8/9)
                                塔防 + 合成(topK 9/9, 首位 1/9)
sensitive(软告警):              合成 + 放置(4/9)  益智 + 叙事(4/9)  模拟经营 + 合成(1/9)
neverTopK:                      放置 + 收集  消除 + 装修  合成 + 关卡
产物: out/ch02_opportunity_sensitivity.json

读法:9 个权重组合里,「合成 + 装扮」和「塔防 + 合成」几乎总是进 Top-3——它们是权重稳健的机会,不管你更看重空白还是变现,都稳居前列,值得优先考虑。而「合成 + 放置」「益智 + 叙事」「模拟经营 + 合成」进不进 Top-3 完全取决于权重怎么定——它们是权重敏感的,软告警提醒:对这几个机会,别急着下结论,先想清楚「我们到底更看重什么」。垫底的「消除 + 装修」在所有权重组合里都进不了 Top-3,可以安全排除。

这条软告警才是本节最有用的产物:它把「我们选了合成 + 装扮」从一句主观判断,变成了「在权重不确定的情况下,这个选择依然稳健」的可复核结论。同时它诚实地告诉你:如果团队里有人坚持「变现力最重要」,那么排名会变——这不是错误,而是需要被摆到台面上讨论的分歧。

从矩阵到下一步

机会矩阵跑完,你手里有了两样东西:一张排序表和一份稳健性报告。它们不是终点,而是下一章的输入:

  • 排序表告诉你在候选里哪些综合分高;
  • 稳健性报告告诉你哪些结论经得起权重质疑。

但矩阵不决定最终做哪个品类——因为机会分只考虑了「市场位置」,还没考虑「团队能力、开发成本、平台约束」。把这两者合起来做加权决策,是第 3 章品类选择模型的事。本节只负责把「哪里有位置」这件事,从直觉变成一张能被打分、被质疑、被复核的表。

小结

  • 差异化机会要同时满足有空白、竞争可控、能变现,三者常互相矛盾,必须加权权衡。
  • 机会矩阵三维:空白度 gap / 竞争度 competition / 变现力 monetization,竞争度作惩罚项。
  • 聚合全用整数算术:机会分 = (wGapgap + wMonetmonetization + wComp(100-competition)) // 100*。
  • 示例跑出:「合成 + 装扮」居首(69);「消除 + 装修」变现最高却因太挤垫底。
  • B 基准遍历权重网格,把机会分成稳健(合成 + 装扮 / 塔防 + 合成)与权重敏感(合成 + 放置等),软告警不硬失败。
  • 真实市场规模与竞争强度属 X;本节全部为示例数据,矩阵只评估市场位置,选型决策在第 3 章。

阅读导航:上一节:2.2 竞品拆解方法 · 下一节:品类分类体系 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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