2.1 内购点设计与定价档位

内购点不是随手摆几个商品,而是把玩家的需求切成可以定价的单元。本节给《合成小岛》一份八个内购点、三档价格的配置表,用一段 S 层仿真把收益按档位聚合、算集中度 HHI,再用一段 P 层内核校验档位表本身是否合法,让「卖什么、定在哪一档」从感觉变成可复算的结构。

本节目标:把「卖什么、每样定多少钱」写成一份可校验的内购点表,并让收益结构由档位聚合与集中度两个数字说清楚。

站内有一组 25 篇的小游戏服务平台专题 ,讲的是做一个小游戏服务平台——SaaS 创业口径:平台怎么搭、开发者怎么接、分成怎么算。本章和它不重叠:那边关心「怎么建一个让别做游戏的平台」,本章关心「在平台上做一款游戏,内购点怎么设计」,主角是《合成小岛》这一个产品自己的付费结构。

广告是第 1 章的事,LTV 与回本是第 3 章的事,折扣与价格弹性是第 4 章的事。本节只处理一件事:内购点(IAP point)本身怎么切、怎么归档、怎么被校验。

2.1 内购点设计与定价档位

2.1.1 内购点不是商品清单,是需求的切分

新手做变现,第一反应是打开后台,摆一排商品:6 元、30 元、68 元、128 元。这么做能跑起来,但它回答不了一个问题——玩家为什么买这一档而不是那一档。内购点设计的起点不是价格,而是需求:玩家在什么时刻、被什么卡住、愿意为什么掏钱。

《合成小岛》是「合成 + 放置 + 关卡 + 装扮」的轻度游戏,它的付费需求可以切成四类:

  • 便利:不想等、不想看广告。复活、去广告、跳过冷却都属于这一类。特点是单价低、触发频繁、几乎不改变数值强度。
  • 成长:想更快变强。体力补给、成长基金属于这一类。它直接加速进度,所以定价要跟「免费玩家能拿到的进度」对齐,不能把免费路径堵死。
  • 外观:想让自己好看、想收藏。装扮包、限定皮肤属于这一类。它不改变强度,是最「干净」的付费点,也是后付费玩家的主战场。
  • 转化:把免费玩家变成付费玩家的第一单。首充礼包属于这一类。它往往赔本赚吆喝,目的是跨过「第一次付费」的心理门槛。

把这四类需求各切出一两个内购点,就得到了一张内购点表。表里的每一行不是「一个商品」,而是「一类需求的定价入口」。

2.1.2 一张内购点表与三档价格(D 层)

下面这份是 D 层配置:八个内购点,每个记下它的形态(消耗 / 一次性 / 订阅)、它切的是哪一类需求、价格(分)、以及「每千名 DAU 的日均购买人数」这个付费强度假设;外加一张三档价格表。它是后面仿真与校验唯一的输入。

{
  "version": 1,
  "game": "merge-island",
  "note": "内购点表与定价档位(D 层)。priceFen 为人民币分;buyersPerDauPermille 是「每千名 DAU 的日均购买人数」示例假设。真实付费分布需真实业务环境,命令行不可复现(X)。",
  "dau": 10000,
  "priceTiers": [
    { "id": "low",  "name": "低价档", "maxFen": 600 },
    { "id": "mid",  "name": "中价档", "maxFen": 1800 },
    { "id": "high", "name": "高价档", "maxFen": 1000000 }
  ],
  "points": [
    { "id": "revive",      "name": "关卡复活",   "kind": "consume",      "demand": "便利", "priceFen": 100,  "buyersPerDauPermille": 42 },
    { "id": "starter",     "name": "首充双倍",   "kind": "oneTime",      "demand": "转化", "priceFen": 600,  "buyersPerDauPermille": 8 },
    { "id": "adfree-week", "name": "去广告周卡", "kind": "subscription", "demand": "便利", "priceFen": 600,  "buyersPerDauPermille": 6 },
    { "id": "stamina",     "name": "体力补给",   "kind": "consume",      "demand": "成长", "priceFen": 600,  "buyersPerDauPermille": 7 },
    { "id": "deco-pack",   "name": "岛屿装扮包", "kind": "oneTime",      "demand": "外观", "priceFen": 1200, "buyersPerDauPermille": 4 },
    { "id": "skin-limited","name": "限定皮肤",   "kind": "oneTime",      "demand": "外观", "priceFen": 1800, "buyersPerDauPermille": 2 },
    { "id": "growth-fund", "name": "成长基金",   "kind": "oneTime",      "demand": "成长", "priceFen": 3000, "buyersPerDauPermille": 3 },
    { "id": "whale-chest", "name": "豪华宝箱",   "kind": "consume",      "demand": "外观", "priceFen": 3000, "buyersPerDauPermille": 1 }
  ]
}

表里有三个决定值得单独说:

  • 价格只有三档:600 分以下、1800 分以下、其余。档位不是越多越好——档位是给玩家做选择的,超过三档,选择成本就盖过了价值差异。低价档负责「顺手买」,中价档负责「认真买」,高价档负责「重度买」。
  • 便利类内购点(复活、去广告)故意压在最便宜的档。它们的价值不在单价,而在触发频率:单价低、买的人多,撑起的是付费覆盖而不是 ARPPU。
  • 形态决定收入的形状。消耗型(复活、体力)按次买、可以反复买;一次性(首充、装扮、基金)只买一次,是付费的「天花板」;订阅型(去广告周卡)介于两者之间,用续费把一次购买摊成一段时间的收入。

2.1.3 档位聚合与集中度(S 层)

表是声明,声明要能算出结果。下面这段是 S 层仿真脚本,它读上面那份配置,把每个内购点的日均收益算出来(priceFen * buyersPerDauPermille // 100,单位是 0.1 分/DAU),再按价格档位聚合,并算一个集中度指标 HHI 与付费覆盖,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l02_iap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l02_iap.py --config config/ --out out/
"""内购点表 → 定价档位聚合与收入结构(S 层)。

读 config/l02_iap.json:每个内购点有价格(分)与「每千名 DAU 日均购买人数」。
按价格归入低/中/高三档,算每点日均收益、档位汇总、集中度 HHI 与付费覆盖,
产出 JSON + CSV + SVG。全程整数,收益用「0.1 分」的 deciFen 记账。

整数公式(全程地板除):
  revenueDeciFen = priceFen * buyersPerDauPermille // 100
  sharePermille  = revenueDeciFen * 1000 // total
  hhiPermille    = sum(sharePermille^2) // 1000
  arppuFen       = totalDeciFen * 100 // buyerSumPermille

注意:priceFen 与 buyersPerDauPermille 均为示例假设;真实付费分布需真实业务环境,
命令行不可复现(X)。
"""

from __future__ import annotations

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

COLORS = {"low": "#2563eb", "mid": "#059669", "high": "#f59e0b"}


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "l02_iap.json"), encoding="utf-8") as f:
        return json.load(f)


def tier_of(price_fen, tiers):
    """按价格上界归入第一档;超出所有上界时归最后一档。"""
    for t in tiers:
        if price_fen <= t["maxFen"]:
            return t["id"]
    return tiers[-1]["id"]


def draw_svg(path, rows):
    """纯 Python SVG:每个内购点一根横条,长度 ∝ 日均收益,颜色按定价档位。"""
    W, PAD, ROW = 760, 170, 34
    H = 66 + ROW * len(rows)
    bar_max = W - PAD - 90
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》内购点日均收益(示例参数,长度 ∝ 0.1 分/DAU)"
    )
    hi = max(r["revenueDeciFen"] for r in rows) or 1
    for i, r in enumerate(rows):
        y = 48 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
                                     "fill": "#374151"}).text = f'{r["name"]} {r["priceFen"]}分'
        w = bar_max * r["revenueDeciFen"] // hi
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 3), "width": str(w), "height": "18",
                                     "rx": "4", "fill": COLORS.get(r["tier"], "#64748b")})
        ET.SubElement(root, "text", {"x": str(PAD + w + 8), "y": str(y + 17), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'{r["revenueDeciFen"]} · {r["sharePermille"]}‰'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


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)
    tiers = cfg["priceTiers"]
    rows = [{"id": p["id"], "name": p["name"], "kind": p["kind"], "demand": p["demand"],
             "priceFen": p["priceFen"], "buyersPerDauPermille": p["buyersPerDauPermille"],
             "tier": tier_of(p["priceFen"], tiers),
             "revenueDeciFen": p["priceFen"] * p["buyersPerDauPermille"] // 100} for p in cfg["points"]]
    total = sum(r["revenueDeciFen"] for r in rows)
    for r in rows:
        r["sharePermille"] = r["revenueDeciFen"] * 1000 // total if total else 0
    hhi = sum(r["sharePermille"] ** 2 for r in rows) // 1000
    tier_totals = [{"id": t["id"], "name": t["name"],
                    "pointCount": sum(1 for r in rows if r["tier"] == t["id"]),
                    "revenueDeciFen": sum(r["revenueDeciFen"] for r in rows if r["tier"] == t["id"])}
                   for t in tiers]
    buyer_sum = sum(r["buyersPerDauPermille"] for r in rows)
    arppu_fen = total * 100 // buyer_sum if buyer_sum else 0
    top = max(rows, key=lambda r: r["revenueDeciFen"])

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "l02_iap.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "dau": cfg["dau"], "pointCount": len(rows),
              "iapArpdauDeciFen": total, "buyerSumPermille": buyer_sum, "arppuFen": arppu_fen,
              "hhiPermille": hhi, "topPoint": {"id": top["id"], "name": top["name"],
                                               "sharePermille": top["sharePermille"]},
              "tierTotals": tier_totals, "points": rows,
              "svg": os.path.join(args.out, "l02_iap.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "l02_iap.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "l02_iap.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "kind", "demand", "priceFen", "buyersPerDauPermille",
                    "tier", "revenueDeciFen", "sharePermille"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["kind"], r["demand"], r["priceFen"],
                        r["buyersPerDauPermille"], r["tier"], r["revenueDeciFen"], r["sharePermille"]])

    print(json.dumps({"iapArpdauDeciFen": total, "hhiPermille": hhi, "arppuFen": arppu_fen,
                      "tierTotals": {t["id"]: t["revenueDeciFen"] for t in tier_totals},
                      "topPoint": report["topPoint"]}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑(/opt/homebrew/bin/python3 sim/l02_iap.py --config config/ --out out/)输出:

{"iapArpdauDeciFen": 372, "hhiPermille": 141, "arppuFen": 509, "tierTotals": {"low": 168, "mid": 84, "high": 120}, "topPoint": {"id": "growth-fund", "name": "成长基金", "sharePermille": 241}}

逐条读这份输出:

  • 内购日均收益合计 372(0.1 分/DAU),也就是 37.2 分/DAU。这是 IAP 这一边的 ARPDAU 分量,第 2.2 节会把它和广告的 IAA 分量合起来看。
  • 三档分别是 168 / 84 / 120。低价档最大(168),因为它装着复活、首充、去广告、体力四个点,靠频率取胜;高价档(120)只靠成长基金和豪华宝箱两个点就顶住了中价档,说明重度付费点的单价权重很高。
  • HHI 只有 141‰。HHI 是把每个点的收入占比平方再求和(最大 1000‰ = 只有一个点)。141 意味着收入分散在多个点上,没有单点依赖——这是健康的信号,任何一个内购点下线都不会让收入崩掉。
  • 最赚钱的单点是成长基金,占比 241‰。它价格最高(3000 分)但购买率不低(3‰),是典型的「大额、低频、贡献稳定」的锚点商品。

2.1.4 档位校验内核(P 层)

仿真算的是「给定这份表,收益是多少」;但表本身可能是错的——档位上界写反了、末档没兜住高价点、某个点的形态拼错了。下面这段是 P 层内核,它做两件事:把内购点归入档位并聚合收入,以及校验档位表与内购点自身的合法性。全程整数运算。

// file: core/src/l02/iap.ts
// tier: P
// verify: node --test 'core/test/l02_*.test.ts'
//
// 内购点定价档位内核:把内购点按价格归入低/中/高三档,聚合档位收入、算集中度 HHI,
// 并校验档位表与内购点自身的合法性。与 sim/l02_iap.py 逐位一致。
// 全整数运算(分 / 0.1 分),除法一律地板除,无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface PriceTier { readonly id: string; readonly name: string; readonly maxFen: number }
export interface IapPoint {
  readonly id: string; readonly name: string; readonly kind: string; readonly demand: string;
  readonly priceFen: number; readonly buyersPerDauPermille: number;
}
export interface IapRow { readonly id: string; readonly tier: string; readonly revenueDeciFen: number }
export interface TierTotal { readonly id: string; readonly pointCount: number; readonly revenueDeciFen: number }
export interface IapResult {
  readonly rows: IapRow[]; readonly tierTotals: TierTotal[]; readonly totalDeciFen: number;
  readonly buyerSumPermille: number; readonly hhiPermille: number; readonly violations: string[]; readonly ok: boolean;
}

const KINDS = ["consume", "oneTime", "subscription"];
const fdiv = (a: number, b: number): number => Math.floor(a / b);

/** 按价格上界归入第一档;超出所有上界时归最后一档。 */
export function tierOf(priceFen: number, tiers: readonly PriceTier[]): string {
  for (const t of tiers) if (priceFen <= t.maxFen) return t.id;
  return tiers[tiers.length - 1].id;
}

/** 档位表合法性:上界严格递增、末档兜底、档位非空。 */
export function checkTiers(tiers: readonly PriceTier[]): string[] {
  const v: string[] = [];
  if (tiers.length === 0) return ["档位表为空"];
  for (let i = 1; i < tiers.length; i += 1) {
    if (tiers[i].maxFen <= tiers[i - 1].maxFen) v.push(`档位 ${tiers[i].id} 的上界未严格大于前一档`);
  }
  if (tiers[tiers.length - 1].maxFen < 1000000) v.push("末档上界不足以兜底高价点");
  return v;
}

export function analyzeIap(points: readonly IapPoint[], tiers: readonly PriceTier[]): IapResult {
  const violations = checkTiers(tiers);
  const rows: IapRow[] = [];
  let buyerSumPermille = 0;
  for (const p of points) {
    if (p.priceFen < 0) violations.push(`内购点 ${p.id} 价格为负`);
    if (p.buyersPerDauPermille < 0) violations.push(`内购点 ${p.id} 购买率为负`);
    if (!KINDS.includes(p.kind)) violations.push(`内购点 ${p.id} 的 kind 非法:${p.kind}`);
    rows.push({ id: p.id, tier: tierOf(p.priceFen, tiers), revenueDeciFen: fdiv(p.priceFen * p.buyersPerDauPermille, 100) });
    buyerSumPermille += p.buyersPerDauPermille;
  }
  let totalDeciFen = 0;
  for (const r of rows) totalDeciFen += r.revenueDeciFen;
  let sq = 0;
  if (totalDeciFen > 0) {
    for (const r of rows) { const share = fdiv(r.revenueDeciFen * 1000, totalDeciFen); sq += share * share; }
  }
  const hhiPermille = fdiv(sq, 1000);
  const tierTotals: TierTotal[] = tiers.map((t) => {
    const pts = rows.filter((r) => r.tier === t.id);
    let rev = 0;
    for (const r of pts) rev += r.revenueDeciFen;
    return { id: t.id, pointCount: pts.length, revenueDeciFen: rev };
  });
  return { rows, tierTotals, totalDeciFen, buyerSumPermille, hhiPermille, violations, ok: violations.length === 0 };
}

内核自带一个测试文件 core/test/l02_iap.test.ts,把真实配置与几个构造出来的坏配置都跑一遍。本机实跑(node --test 'core/test/l02_iap.test.ts')输出:

✔ 档位归属:低价 4 点 / 中价 2 点 / 高价 2 点
✔ 聚合确定:总收益 372、档位 168/84/120、HHI 141‰
✔ 单点收益 = price * rate // 100,逐点可复算
✔ 坏档位表可被检出:上界非递增 / 末档不兜底 / kind 非法
ℹ tests 4
ℹ pass 4
ℹ fail 0

两条值得点出:

  • TS 内核与 Python 仿真逐位一致(总收益 372、HHI 141‰)。同一份 config 被两个语言消费、得到同一个数字,靠的是三条纪律:全程整数地板除、不写浮点、不写 Math.random()。这也让「档位结构」这件事可以被两种宿主交叉验证。
  • 坏表能被抓出来:档位上界非递增、末档不兜底高价点、内购点形态写成不认识的值,三种都会被断言拦下。档位表不是文档里的一段描述,而是一旦写错就红的一条测试。

2.1.5 三条设计规则

  1. 先切需求,再定价格。一个内购点对应一类需求(便利 / 成长 / 外观 / 转化),价格是需求的标价,不是拍出来的数字。
  2. 档位控制在三档。低价档靠频率、高价档靠单价、中价档承接犹豫的人;再多就是给玩家出选择题。
  3. 让结构可校验。表写成 JSON,聚合与合法性写成断言,跑在 node --test 里。收入是否依赖单点、档位是否写反,都由数字回答。

小结

  • 内购点设计是把玩家的需求切成可定价的单元,再归入三档价格;判据是需求类型与购买强度,不是直觉。
  • D 层配置把八个内购点与三档价格写成唯一真源;S 层仿真按档位聚合收益、算 HHI 与付费覆盖;P 层内核校验档位表并做同样的聚合。
  • 本机实跑:内购日均收益 372(0.1 分/DAU)、三档 168/84/120、HHI 141‰、最赚单点是成长基金(241‰);四类坏表都被断言抓出。与站内小游戏服务平台专题 的分工:那边是搭平台,这边是做一款游戏——本节管的是这款游戏自己的内购点结构。这些数字是同机相对量级,绝对数值不可移植,可移植的是这套流程。

内购点切好了,接下来的问题是:内购和广告这两条收入曲线怎么配比、会不会互相侵蚀。这是下一节的事。

阅读导航:上一节:目录 · 下一节:混合变现的比例与协同 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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