本节目标:把「卖什么、每样定多少钱」写成一份可校验的内购点表,并让收益结构由档位聚合与集中度两个数字说清楚。
站内有一组 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 三条设计规则
- 先切需求,再定价格。一个内购点对应一类需求(便利 / 成长 / 外观 / 转化),价格是需求的标价,不是拍出来的数字。
- 档位控制在三档。低价档靠频率、高价档靠单价、中价档承接犹豫的人;再多就是给玩家出选择题。
- 让结构可校验。表写成 JSON,聚合与合法性写成断言,跑在
node --test里。收入是否依赖单点、档位是否写反,都由数字回答。
小结
- 内购点设计是把玩家的需求切成可定价的单元,再归入三档价格;判据是需求类型与购买强度,不是直觉。
- D 层配置把八个内购点与三档价格写成唯一真源;S 层仿真按档位聚合收益、算 HHI 与付费覆盖;P 层内核校验档位表并做同样的聚合。
- 本机实跑:内购日均收益 372(0.1 分/DAU)、三档 168/84/120、HHI 141‰、最赚单点是成长基金(241‰);四类坏表都被断言抓出。与站内小游戏服务平台专题 的分工:那边是搭平台,这边是做一款游戏——本节管的是这款游戏自己的内购点结构。这些数字是同机相对量级,绝对数值不可移植,可移植的是这套流程。
内购点切好了,接下来的问题是:内购和广告这两条收入曲线怎么配比、会不会互相侵蚀。这是下一节的事。
阅读导航:上一节:目录 · 下一节:混合变现的比例与协同 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。