本节目标:把「礼包 / 通行证 / 订阅」三种成型的付费产品拆成三种不同的收入公式,并让订阅的续费衰减第一次被算进账里。
站内有一组 25 篇的小游戏服务平台专题 ,讲的是做一个小游戏服务平台——SaaS 创业口径:平台怎么搭、开发者怎么接。本节和它不重叠:那边是平台侧,这边是在一款游戏里把三种成型付费产品各自怎么算账讲清楚,主角是《合成小岛》。
广告形态与 eCPM 模型是第 1 章的事,LTV 与回本是第 3 章的事,价格弹性是第 4 章的事。本节只处理一个交叉问题:礼包、通行证、订阅这三种产品,收入公式长得不一样,怎么各算各的。
2.3 礼包、通行证与订阅
2.3.1 三种产品,三种账
单个内购点(第 2.1 节)可以统一成「单价 × 购买率」,但成型的付费产品不能。它们的商业逻辑不同,账也不同:
- 礼包卖的是感知价值。玩家掏 600 分买一个「价值 2400 分」的包,赚的是划算感。所以礼包的账里必须有一个「感知价值比」,它决定了这个包卖得动不动。
- 通行证卖的是赛季留存。它先收一笔钱,再用一整个赛季的奖励把玩家留下来。它的账要单独看毛利——奖励本身有成本(配发的道具、货币),收入减掉奖励成本才是真赚。
- 订阅卖的是续费衰减。月卡第一次收 1800 分,但只有一部分人下个月接着续,再下个月又少一批。它的账是一条衰减曲线,不是一次性收入。
把这三条公式写进同一份模型,才能横向比「哪个产品更值」。本节的内核就把它们放在一起算。
2.3.2 六件产品的 config(D 层)
下面这份是 D 层配置:六件产品,两件礼包、两件通行证、两件订阅,各自带上自己的专属字段(礼包带 valueFen、通行证带 rewardCostFen、订阅带 renewRatePermille)。它是 P 层内核与 S 层仿真共用的同一份输入。
{
"version": 1,
"game": "merge-island",
"note": "礼包 / 通行证 / 订阅三类付费产品(D 层)。价格、购买率、订阅续费率均为示例假设;真实数据需真实业务环境,命令行不可复现(X)。",
"dau": 10000,
"products": [
{ "id": "starter-bundle", "kind": "bundle", "name": "新手礼包", "priceFen": 600, "buyersPerDauPermille": 9, "valueFen": 2400 },
{ "id": "deco-bundle", "kind": "bundle", "name": "装扮礼包", "priceFen": 1200, "buyersPerDauPermille": 4, "valueFen": 3600 },
{ "id": "season-pass", "kind": "pass", "name": "赛季通行证", "priceFen": 1800, "buyersPerDauPermille": 6, "seasonDays": 56, "rewardCostFen": 900 },
{ "id": "deluxe-pass", "kind": "pass", "name": "豪华通行证", "priceFen": 3000, "buyersPerDauPermille": 2, "seasonDays": 56, "rewardCostFen": 1500 },
{ "id": "monthly-card", "kind": "subscription", "name": "月卡", "priceFen": 1800, "buyersPerDauPermille": 5, "renewRatePermille": 600, "horizonMonths": 6 },
{ "id": "adfree-sub", "kind": "subscription", "name": "去广告订阅", "priceFen": 1200, "buyersPerDauPermille": 3, "renewRatePermille": 720, "horizonMonths": 6 }
]
}
三类产品的专属字段就是三条公式的入口:valueFen 喂给礼包的价值比,rewardCostFen 喂给通行证的毛利,renewRatePermille 喂给订阅的衰减曲线。buyersPerDauPermille 是三类共用的购买率(千分比),统一口径才能横向比。
2.3.3 三类产品的建模内核(P 层)
下面这段是 P 层内核:它按 kind 分派到三条公式——礼包算价值比、通行证算毛利、订阅按「仍在订阅概率」逐月累乘算期望周期收入。全程整数运算,订阅的续费衰减用千分比整数逐月递推,没有任何浮点与随机。
// file: core/src/l02/bundle.ts
// tier: P
// verify: node --test 'core/test/l02_*.test.ts'
//
// 礼包 / 通行证 / 订阅三类付费产品的收入内核,与 sim/l02_bundle.py 逐位一致。
// 订阅用「仍在订阅概率」逐月累乘(千分比整数),求每付费人的期望周期收入。
// 全整数运算(deciFen = 0.1 分/DAU),除法一律地板除,无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface Product {
readonly id: string; readonly kind: string; readonly name: string; readonly priceFen: number;
readonly buyersPerDauPermille: number; readonly valueFen?: number; readonly rewardCostFen?: number;
readonly seasonDays?: number; readonly renewRatePermille?: number; readonly horizonMonths?: number;
}
export interface ProductRow {
readonly id: string; readonly kind: string; readonly name: string; readonly priceFen: number;
readonly revenueDeciFen: number; readonly valueRatioPermille?: number;
readonly rewardCostRatioPermille?: number; readonly marginDeciFen?: number;
readonly expRevFenPerBuyer?: number; readonly effMonthsPermille?: number; readonly curvePermille?: number[];
}
export interface KindTotal { readonly kind: string; readonly productCount: number; readonly revenueDeciFen: number }
export interface BundleResult {
readonly rows: ProductRow[]; readonly kindTotals: KindTotal[]; readonly totalDeciFen: number;
readonly violations: string[]; readonly ok: boolean;
}
const KINDS = ["bundle", "pass", "subscription"];
const fdiv = (a: number, b: number): number => Math.floor(a / b);
/** 订阅期望曲线:p_0=1000,逐月 ×renewRate‰;返回期望周期收入与期望有效月数(千分月)。 */
export function subscriptionCurve(priceFen: number, renewPermille: number, horizonMonths: number) {
let p = 1000;
let expRevFenPerBuyer = 0;
let effMonthsPermille = 0;
const curve: number[] = [];
for (let m = 0; m < horizonMonths; m += 1) {
expRevFenPerBuyer += fdiv(priceFen * p, 1000);
effMonthsPermille += p;
curve.push(p);
p = fdiv(p * renewPermille, 1000);
}
return { expRevFenPerBuyer, effMonthsPermille, curve };
}
export function analyzeProducts(products: readonly Product[]): BundleResult {
const violations: string[] = [];
const rows: ProductRow[] = [];
for (const p of products) {
if (!KINDS.includes(p.kind)) violations.push(`产品 ${p.id} 的 kind 非法:${p.kind}`);
const base = {
id: p.id, kind: p.kind, name: p.name, priceFen: p.priceFen,
revenueDeciFen: fdiv(p.priceFen * p.buyersPerDauPermille, 100),
};
if (p.kind === "bundle") {
const valueFen = p.valueFen ?? 0;
if (valueFen < p.priceFen) violations.push(`产品 ${p.id}:礼包感知价值低于售价`);
rows.push({ ...base, valueRatioPermille: fdiv(valueFen * 1000, p.priceFen) });
} else if (p.kind === "pass") {
const cost = p.rewardCostFen ?? 0;
if (cost >= p.priceFen) violations.push(`产品 ${p.id}:通行证奖励成本不低于售价`);
rows.push({
...base, rewardCostRatioPermille: fdiv(cost * 1000, p.priceFen),
marginDeciFen: fdiv((p.priceFen - cost) * p.buyersPerDauPermille, 100),
});
} else {
const renew = p.renewRatePermille ?? 0;
const horizon = p.horizonMonths ?? 0;
if (!(renew >= 1 && renew <= 1000)) violations.push(`产品 ${p.id}:续费率不在 1–1000‰`);
if (horizon < 1) violations.push(`产品 ${p.id}:订阅期数小于 1`);
const c = subscriptionCurve(p.priceFen, renew, horizon);
rows.push({
...base, expRevFenPerBuyer: c.expRevFenPerBuyer, effMonthsPermille: c.effMonthsPermille,
curvePermille: c.curve,
revenueDeciFen: fdiv(c.expRevFenPerBuyer * p.buyersPerDauPermille, 100),
});
}
}
let totalDeciFen = 0;
for (const r of rows) totalDeciFen += r.revenueDeciFen;
const kindTotals: KindTotal[] = KINDS.map((k) => {
const pts = rows.filter((r) => r.kind === k);
let rev = 0;
for (const r of pts) rev += r.revenueDeciFen;
return { kind: k, productCount: pts.length, revenueDeciFen: rev };
});
return { rows, kindTotals, totalDeciFen, violations, ok: violations.length === 0 };
}
内核自带一个测试 core/test/l02_bundle.test.ts,本机实跑(node --test 'core/test/l02_bundle.test.ts')输出:
✔ 三类汇总:礼包 102 / 通行证 168 / 订阅 324,合计 594
✔ 礼包感知价值比:新手 4000‰、装扮 3000‰
✔ 通行证毛利:赛季 (1800−900)×6//100=54,豪华 (3000−1500)×2//100=30
✔ 订阅期望:月卡 2.382 月 / 4286 分,去广告订阅 3.069 月 / 3681 分
✔ 坏配置可被检出:礼包价值低于售价 / 通行证成本不低于售价 / 续费率越界
ℹ tests 5
ℹ pass 5
ℹ fail 0
三条公式的读法:
- 礼包:新手礼包价值比 4000‰(感知价值是售价的 4 倍),装扮礼包 3000‰。价值比越高越「划算」,但高到离谱会伤定价锚点——它是礼包的核心杠杆。
- 通行证:赛季通行证毛利 (1800−900)×6//100 = 54,豪华通行证 (3000−1500)×2//100 = 30。豪华版单价更高、毛利反而更低,因为它的奖励成本也更高——通行证不能只看售价。
- 订阅:月卡的期望有效周期是 2382‰ ≈ 2.38 个月,每付费人期望贡献 4286 分;去广告订阅续费率更高(720‰),期望周期 3.07 个月。续费率每高一点,期望周期就长一截——这是订阅与一次性内购最大的差别。
2.3.4 三类收入的横向对比(S 层)
内核讲完了逻辑,但它只在命令行里跑。下面这段是 S 层仿真:读同一份 config,逐产品算日均收入,再按三类汇总,产出 JSON + CSV。
#!/usr/bin/env python3
# file: sim/l02_bundle.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l02_bundle.py --config config/ --out out/
"""礼包 / 通行证 / 订阅三类付费产品的收入结构(S 层)。读 config/l02_bundle.json,按产品
形态分别建模,产出 JSON + CSV。全程整数(deciFen = 0.1 分/DAU),除法一律地板除。
bundle(礼包):revenueDeciFen = priceFen * buyersPerDauPermille // 100
valueRatioPermille = valueFen * 1000 // priceFen
pass(通行证):revenueDeciFen = priceFen * buyersPerDauPermille // 100
marginDeciFen = (priceFen - rewardCostFen) * buyersPerDauPermille // 100
subscription:p_0=1000,p_m = p_{m-1} * renewRatePermille // 1000
expRevFenPerBuyer = sum_m (priceFen * p_m // 1000);effMonthsPermille = sum_m p_m
revenueDeciFen = expRevFenPerBuyer * buyersPerDauPermille // 100
注意:所有参数均为示例假设;真实付费分布需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
KINDS = ("bundle", "pass", "subscription")
def load(cfg_dir):
with open(os.path.join(cfg_dir, "l02_bundle.json"), encoding="utf-8") as f:
return json.load(f)
def build_row(p):
row = {"id": p["id"], "kind": p["kind"], "name": p["name"], "priceFen": p["priceFen"],
"buyersPerDauPermille": p["buyersPerDauPermille"],
"revenueDeciFen": p["priceFen"] * p["buyersPerDauPermille"] // 100}
if p["kind"] == "bundle":
row["valueRatioPermille"] = p["valueFen"] * 1000 // p["priceFen"]
elif p["kind"] == "pass":
row["marginDeciFen"] = (p["priceFen"] - p["rewardCostFen"]) * p["buyersPerDauPermille"] // 100
elif p["kind"] == "subscription":
prob, exp_rev, eff = 1000, 0, 0
for _ in range(p["horizonMonths"]):
exp_rev += p["priceFen"] * prob // 1000
eff += prob
prob = prob * p["renewRatePermille"] // 1000
row["expRevFenPerBuyer"] = exp_rev
row["effMonthsPermille"] = eff
row["revenueDeciFen"] = exp_rev * p["buyersPerDauPermille"] // 100
return row
def violations_of(products):
v = []
for p in products:
if p["kind"] == "bundle" and p["valueFen"] < p["priceFen"]:
v.append(f'{p["id"]}:礼包感知价值低于售价')
if p["kind"] == "pass" and p["rewardCostFen"] >= p["priceFen"]:
v.append(f'{p["id"]}:通行证奖励成本不低于售价')
if p["kind"] == "subscription" and not (1 <= p["renewRatePermille"] <= 1000):
v.append(f'{p["id"]}:续费率不在 1–1000‰')
if p["kind"] not in KINDS:
v.append(f'{p["id"]}:kind 非法')
return v
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)
rows = [build_row(p) for p in cfg["products"]]
total = sum(r["revenueDeciFen"] for r in rows)
for r in rows:
r["sharePermille"] = r["revenueDeciFen"] * 1000 // total if total else 0
kind_totals = [{"kind": k, "revenueDeciFen": sum(r["revenueDeciFen"] for r in rows if r["kind"] == k)}
for k in KINDS]
v = violations_of(cfg["products"])
os.makedirs(args.out, exist_ok=True)
report = {"version": cfg["version"], "game": cfg["game"], "arpdauDeciFen": total,
"kindTotals": kind_totals, "products": rows, "violations": v, "ok": len(v) == 0}
with open(os.path.join(args.out, "l02_bundle.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "l02_bundle.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=["id", "kind", "name", "priceFen", "buyersPerDauPermille",
"revenueDeciFen", "sharePermille"], extrasaction="ignore")
w.writeheader()
w.writerows(rows)
print(json.dumps({
"arpdauDeciFen": total, "kindTotals": {t["kind"]: t["revenueDeciFen"] for t in kind_totals},
"subscriptions": [{"id": r["id"], "revenueDeciFen": r["revenueDeciFen"],
"effMonthsPermille": r["effMonthsPermille"],
"expRevFenPerBuyer": r["expRevFenPerBuyer"]}
for r in rows if r["kind"] == "subscription"],
"violations": v}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(/opt/homebrew/bin/python3 sim/l02_bundle.py --config config/ --out out/)输出摘要:
{"arpdauDeciFen": 594, "kindTotals": {"bundle": 102, "pass": 168, "subscription": 324},
"subscriptions": [{"id": "monthly-card", "revenueDeciFen": 214, "effMonthsPermille": 2382, "expRevFenPerBuyer": 4286},
{"id": "adfree-sub", "revenueDeciFen": 110, "effMonthsPermille": 3069, "expRevFenPerBuyer": 3681}],
"violations": []}
三类汇总 102 : 168 : 324,合计 594(0.1 分/DAU)——订阅这一类贡献最大(324,占 55%),尽管它的单价不是最高。这正是续费衰减被算进来之后才看得见的结构:一次性卖出的礼包只贡献 102,而两件订阅靠「滚起来」的续费周期贡献了 324。命令行内核与 Python 仿真给出的 594 / 102 / 168 / 324 / 214 / 2382 / 4286 / 110 / 3069 / 3681 逐位一致——同一份 config,两个宿主,同一组数字。
2.3.5 三条判断
- 礼包看价值比,别只看售价。感知价值比太低没人买,太高会拉低整店定价锚点;两件礼包一高一低是刻意的档位设计。
- 通行证看毛利,不看售价。奖励有成本,豪华版售价更高但毛利更低是常态;先把
rewardCostFen标出来再定价。 - 订阅看期望周期,不看首月。续费率把一次性收入拉成一条曲线;月卡 2.38 个月、去广告订阅 3.07 个月的差别,全部来自续费率这一个数字。
小结
- 礼包、通行证、订阅是三种不同的账:价值比 / 毛利 / 续费衰减,不能统一成「单价 × 购买率」。
- D 层配置把六件产品与三条专属字段写成唯一真源;P 层内核算三类公式;S 层仿真按类汇总出 102 : 168 : 324。
- 本机实跑:合计 594(0.1 分/DAU),订阅贡献最大(324,占 55%),月卡期望周期 2.38 个月。与站内小游戏服务平台专题 的分工:那边搭平台,这边算清一款游戏里三类付费产品的账。这些数字是同机相对量级,绝对数值不可移植,可移植的是这套三类公式。
- 参数口径:价格、购买率、续费率均为示例假设;真实付费分布需真实业务环境,命令行不可复现(X)。
三类产品的账算清了,接下来要问的是:这些收入加起来,一款游戏的 DAU × ARPDAU 到底能不能覆盖买量成本、多久回本。这是下一章的事。
阅读导航:上一节:混合变现的比例与协同 · 下一节:收入建模:DAU×ARPDAU 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。