站内《小游戏开发实战》卷三目录 把变现侧的模型算得很细,游戏商业核心指标详解 定义了 ARPDAU、LTV 这些口径,上一节我们又把单用户的 CAC 与 LTV 接成了单位账。但单位账回答的是「一个用户赚不赚」,它停在单点上;一门真实的生意是按月运转的——每个月都要发工资、付服务器、投下一波量,账上还得有现金撑着。本节不重做卷三 3 章的收入模型,也不重做 12.2 的「收入确认 vs 现金到账」时点差,而是把整月的经营账算出来:收入、变动成本、固定成本、营业利润,再把现金按月滚一遍,回答一个操盘手每月必须回答的问题——这个月我到底赚了没有,账上的钱还够不够。 分工是:卷三 3 章算「一个用户能赚多少」,12.2 算「一笔钱什么时候到账」,本节算「一个月这门生意是盈是亏、现金还剩多少」。
两张表:利润表与现金流量表
月度经营账其实是两张表叠在一起:
- 利润表(P&L):这个月「赚了多少」。
收入 − 变动成本 − 固定成本 = 营业利润。它看的是经营效率——生意本身成不成立。 - 现金流量表:这个月「钱到没到」。
期初现金 + 收进来的 − 付出去的 = 期末现金。它看的是生存能力——生意能不能撑到赚钱那天。
两张表经常对不上,而且转正月份与现金最低点常常不是同一个月。原因就是 12.2 讲过的时点差:这个月确认的收入,下个月才到账;但这个月的成本(工资、投放)当月就得付。于是哪怕某个月营业利润已经转正,现金可能还在往下掉——这正是把小团队拖垮的地方:账面赚钱,账上没钱。
月度账公式
把两张表写成整数公式(金额用「分」,比例用千分比):
netRevenue(m) = grossRevenueFen(m) × (1000 − sharePermille(m)) // 1000 # 扣平台后的净收入
opProfit(m) = netRevenue(m) − uaSpendFen(m) − variableCostFen(m) − fixedCostFen(m)
cashIn(m) = netRevenue(m − collectDelayMonths) # 收入延后到账(越界记 0)
cashOut(m) = uaSpendFen(m) + variableCostFen(m) + fixedCostFen(m) # 成本当月支付
cumCash(m) = startCashFen + Σ_{k≤m} netCash(k) # 累计现金
cumProfit(m) = Σ_{k≤m} opProfit(k) # 累计营业利润
三个派生量决定这门生意的成败:breakEvenMonth(营业利润首次转正的月)、cashLowFen / cashLowMonth(现金最低点及其月份)、annualProfitFen(全年营业利润合计)。只看年度利润会漏掉现金最低点——一家公司可以全年盈利却因为某个月的现金断裂而死掉。
S 层仿真:逐月把账滚一遍
下面这段是 S 层仿真脚本 sim/o14_monthly.py。它读 config/o14_monthly.json 的 12 个月,逐月算营业利润与净现金,滚出累计现金与累计利润,找出转正月份、现金最低点与断裂月份,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o14_monthly.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o14_monthly.py --config config/ --out out/
"""月度经营账与现金流:把「收入 - 变动成本 - 固定成本 = 营业利润」逐月算出来,再把现金滚一遍。
读 config/o14_monthly.json 的 12 个月。每个月:
netRevenue(m) = grossRevenueFen(m) * (1000 - sharePermille(m)) // 1000 # 扣平台后的净收入
opProfit(m) = netRevenue(m) - uaSpendFen(m) - variableCostFen(m) - fixedCostFen(m)
cashIn(m) = netRevenue(m - collectDelayMonths) # 收入延后到账(越界记 0)
cashOut(m) = uaSpendFen(m) + variableCostFen(m) + fixedCostFen(m) # 成本当月支付
netCash(m) = cashIn(m) - cashOut(m)
cumCash(m) = startCashFen + Σ_{k<=m} netCash(k)
cumProfit(m) = Σ_{k<=m} opProfit(k)
派生:breakEvenMonth(首个营业利润转正的月,否则 -1)、cashLowFen / cashLowMonth(现金最低点)、
runoutMonth(首个累计现金为负的月,否则 -1)、annualProfitFen(12 个月营业利润合计)。
注意营业利润转正的月份与现金最低点常常不在同一个月——这正是「利润表」与「现金流量表」的差。
全程整数、地板除,无浮点。产出 out/o14_monthly.json + .csv + .svg。
真实扣点、真实账期与真实成本结构需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def simulate(cfg: dict, ua_scale_permille: int = 1000) -> dict:
delay = cfg["collectDelayMonths"]
rows = []
cum_cash = cfg["startCashFen"]
cum_profit = 0
break_even = -1
cash_low, cash_low_month = cum_cash, 0
runout = -1
for i, mo in enumerate(cfg["months"]):
ua = mo["uaSpendFen"] * ua_scale_permille // 1000
net = mo["grossRevenueFen"] * (1000 - mo["sharePermille"]) // 1000
op = net - ua - mo["variableCostFen"] - mo["fixedCostFen"]
prev_net = cfg["months"][i - delay]["grossRevenueFen"] * \
(1000 - cfg["months"][i - delay]["sharePermille"]) // 1000 if i - delay >= 0 else 0
cash_out = ua + mo["variableCostFen"] + mo["fixedCostFen"]
net_cash = prev_net - cash_out
cum_cash += net_cash
cum_profit += op
if break_even < 0 and op > 0:
break_even = mo["m"]
if cum_cash < cash_low:
cash_low, cash_low_month = cum_cash, mo["m"]
if runout < 0 and cum_cash < 0:
runout = mo["m"]
rows.append({"m": mo["m"], "name": mo["name"], "netRevenueFen": net, "uaSpendFen": ua,
"variableCostFen": mo["variableCostFen"], "fixedCostFen": mo["fixedCostFen"],
"opProfitFen": op, "cashInFen": prev_net, "cashOutFen": cash_out,
"netCashFen": net_cash, "cumCashFen": cum_cash, "cumProfitFen": cum_profit})
return {"rows": rows, "breakEvenMonth": break_even, "annualProfitFen": cum_profit,
"cashLowFen": cash_low, "cashLowMonth": cash_low_month, "runoutMonth": runout,
"finalCashFen": cum_cash}
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:逐月营业利润柱(正蓝负红)+ 累计现金折线,零线画横线。"""
W, H, PAD_L, PAD_B, PAD_T, PAD_R = 760, 320, 56, 40, 30, 130
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": "20", "font-size": "14",
"fill": "#111827"}).text = "《合成小岛》月度营业利润(柱)与累计现金(线)"
plot_w, plot_h = W - PAD_L - PAD_R, H - PAD_T - PAD_B
n = len(rows)
lo = min([min(r["opProfitFen"], r["cumCashFen"]) for r in rows] + [0])
hi = max([max(r["opProfitFen"], r["cumCashFen"]) for r in rows] + [1])
span = hi - lo or 1
y = lambda v: PAD_T + plot_h - plot_h * (v - lo) // span
bw = plot_w // max(n, 1)
zero = y(0)
ET.SubElement(root, "line", {"x1": str(PAD_L), "y1": str(zero), "x2": str(W - PAD_R),
"y2": str(zero), "stroke": "#9ca3af"})
for i, r in enumerate(rows):
x = PAD_L + i * bw
v = r["opProfitFen"]
top, bot = (y(v), zero) if v >= 0 else (zero, y(v))
ET.SubElement(root, "rect", {"x": str(x + 2), "y": str(top), "width": str(bw - 4),
"height": str(max(1, bot - top)),
"fill": "#2563eb" if v >= 0 else "#ef4444"})
pts = " ".join(f'{PAD_L + i * bw + bw // 2},{y(r["cumCashFen"])}' for i, r in enumerate(rows))
ET.SubElement(root, "polyline", {"points": pts, "fill": "none", "stroke": "#16a34a", "stroke-width": "2"})
ET.SubElement(root, "text", {"x": str(PAD_L), "y": str(H - 10), "font-size": "11",
"fill": "#2563eb"}).text = "蓝=月营业利润>0 红=亏损 绿=累计现金"
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()
with open(os.path.join(args.config, "o14_monthly.json"), encoding="utf-8") as f:
cfg = json.load(f)
sim = simulate(cfg)
digest = (f"breakEven:{sim['breakEvenMonth']},annualProfit:{sim['annualProfitFen']},"
f"cashLow:{sim['cashLowFen']},cashLowMonth:{sim['cashLowMonth']},"
f"finalCash:{sim['finalCashFen']}")
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o14_monthly.svg"), sim["rows"])
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"startCashFen": cfg["startCashFen"], "collectDelayMonths": cfg["collectDelayMonths"],
"months": sim["rows"], "breakEvenMonth": sim["breakEvenMonth"],
"annualProfitFen": sim["annualProfitFen"], "cashLowFen": sim["cashLowFen"],
"cashLowMonth": sim["cashLowMonth"], "runoutMonth": sim["runoutMonth"],
"finalCashFen": sim["finalCashFen"], "digest": digest,
"svg": os.path.join(args.out, "o14_monthly.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "o14_monthly.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o14_monthly.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("m", "name", "netRevenueFen", "uaSpendFen", "variableCostFen", "fixedCostFen",
"opProfitFen", "cashInFen", "cashOutFen", "netCashFen", "cumCashFen", "cumProfitFen")
w.writerow(cols)
for r in sim["rows"]:
w.writerow([r[k] for k in cols])
print(json.dumps({"breakEvenMonth": sim["breakEvenMonth"],
"annualProfitFen": sim["annualProfitFen"],
"cashLowFen": sim["cashLowFen"], "cashLowMonth": sim["cashLowMonth"],
"runoutMonth": sim["runoutMonth"], "finalCashFen": sim["finalCashFen"],
"opProfitByMonth": [r["opProfitFen"] for r in sim["rows"]],
"digest": digest}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o14_monthly.json。12 个月覆盖从冷启动放量到稳定运营的全过程,扣点、账期、成本结构都是「公开可观察机制」口径的建模假设:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"startCashFen": 20000000,
"collectDelayMonths": 1,
"note": "月度经营账与现金流参数,口径是「公开可观察机制」的建模假设,不是任何真实账本。grossRevenueFen=当月毛收入(分);sharePermille=平台综合扣点千分比;uaSpendFen=当月买量支出(分);variableCostFen=变动成本(服务器/支付通道费,分);fixedCostFen=固定成本(团队/工具/资质摊销,分)。收入当月确认、collectDelayMonths 个月后到账;成本当月支付。startCashFen=期初现金。真实扣点、真实账期与真实成本结构需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"months": [
{ "m": 1, "name": "2026-01", "grossRevenueFen": 6000000, "sharePermille": 400, "uaSpendFen": 4000000, "variableCostFen": 400000, "fixedCostFen": 2000000 },
{ "m": 2, "name": "2026-02", "grossRevenueFen": 9000000, "sharePermille": 400, "uaSpendFen": 5000000, "variableCostFen": 600000, "fixedCostFen": 2000000 },
{ "m": 3, "name": "2026-03", "grossRevenueFen": 14000000, "sharePermille": 400, "uaSpendFen": 6000000, "variableCostFen": 900000, "fixedCostFen": 2000000 },
{ "m": 4, "name": "2026-04", "grossRevenueFen": 20000000, "sharePermille": 400, "uaSpendFen": 7000000, "variableCostFen": 1300000, "fixedCostFen": 2000000 },
{ "m": 5, "name": "2026-05", "grossRevenueFen": 26000000, "sharePermille": 400, "uaSpendFen": 8000000, "variableCostFen": 1700000, "fixedCostFen": 2200000 },
{ "m": 6, "name": "2026-06", "grossRevenueFen": 32000000, "sharePermille": 400, "uaSpendFen": 9000000, "variableCostFen": 2100000, "fixedCostFen": 2200000 },
{ "m": 7, "name": "2026-07", "grossRevenueFen": 36000000, "sharePermille": 400, "uaSpendFen": 9000000, "variableCostFen": 2400000, "fixedCostFen": 2200000 },
{ "m": 8, "name": "2026-08", "grossRevenueFen": 38000000, "sharePermille": 400, "uaSpendFen": 8500000, "variableCostFen": 2500000, "fixedCostFen": 2200000 },
{ "m": 9, "name": "2026-09", "grossRevenueFen": 38000000, "sharePermille": 400, "uaSpendFen": 8000000, "variableCostFen": 2500000, "fixedCostFen": 2200000 },
{ "m": 10, "name": "2026-10", "grossRevenueFen": 36000000, "sharePermille": 400, "uaSpendFen": 7000000, "variableCostFen": 2400000, "fixedCostFen": 2200000 },
{ "m": 11, "name": "2026-11", "grossRevenueFen": 34000000, "sharePermille": 400, "uaSpendFen": 6000000, "variableCostFen": 2300000, "fixedCostFen": 2200000 },
{ "m": 12, "name": "2026-12", "grossRevenueFen": 32000000, "sharePermille": 400, "uaSpendFen": 5500000, "variableCostFen": 2100000, "fixedCostFen": 2200000 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o14_monthly.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):
| 指标 | 数值 |
|---|---|
| 营业利润转正月份 | 第 4 月 |
| 全年营业利润(分) | 62800000 |
| 现金最低点(分) | 4200000 |
| 现金最低点月份 | 第 4 月 |
| 现金断裂月份 | 无 |
| 期末现金(分) | 63600000 |
前三个月的营业利润是 −2800000 / −2200000 / −500000 分——冷启动期买量支出远高于收入,这是正常的,不是失败。第 4 月营业利润转正(+1700000 分),但现金最低点也恰好落在第 4 月(4200000 分,约 4.2 万元):因为确认的收入要下个月才到账,这个月的投放却要当月付。这就是「利润表」与「现金流量表」的差——账面第 4 月开始赚钱,账上第 4 月最危险。如果期初现金不是 2000 万分而是 300 万分,第 3 月就会现金断裂,生意直接死掉,哪怕它全年能赚 628000 元。
B 基准:转正月份对买量预算有多敏感
营业利润转正的月份几乎完全由买量支出决定,而买量预算是你唯一能主动拧的旋钮。所以要问一个稳健性问题:「如果每月买量支出整体 ±10%,转正月份、年度利润、现金低点会怎么变?」下面这段是 B 层基准脚本 sim/o14_monthly_bench.py,把每月 uaSpendFen 乘 900‰ / 1000‰ / 1100‰ 重跑仿真,软告警不硬失败。
#!/usr/bin/env python3
# file: sim/o14_monthly_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o14_monthly_bench.py --config config/ --out out/
"""B 基准:月度经营账对「买量预算」的敏感度。
营业利润转正的月份,几乎完全由买量支出决定——而买量预算是你唯一能主动拧的旋钮。
所以要问:「如果每月买量支出整体 ±10%,转正月份、年度利润、现金低点会怎么变?」
做法:把每月 uaSpendFen 整体乘 900‰ / 1000‰ / 1100‰,复用 simulate() 重跑,比较关键指标。
软告警:若 ±10% 之间转正月份漂移,或 +10% 时出现现金断裂(runout),打 WARN。退出码恒为 0。
全整数、地板除,无浮点。产出 out/o14_monthly_bench.json。
"""
from __future__ import annotations
import argparse
import json
import os
from o14_monthly import simulate
SCALES = (900, 1000, 1100)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "o14_monthly.json"), encoding="utf-8") as f:
cfg = json.load(f)
base = simulate(cfg)
cases = []
for s in SCALES:
sim = simulate(cfg, s)
cases.append({"uaScalePermille": s, "breakEvenMonth": sim["breakEvenMonth"],
"annualProfitFen": sim["annualProfitFen"], "cashLowFen": sim["cashLowFen"],
"cashLowMonth": sim["cashLowMonth"], "runoutMonth": sim["runoutMonth"],
"finalCashFen": sim["finalCashFen"]})
months = {c["breakEvenMonth"] for c in cases}
runout = any(c["runoutMonth"] > 0 for c in cases)
warn = (len(months) > 1) or runout
os.makedirs(args.out, exist_ok=True)
rep = {"version": cfg["version"], "source": "sim/o14_monthly_bench.py", "scales": list(SCALES),
"baseBreakEvenMonth": base["breakEvenMonth"], "cases": cases,
"distinctBreakEvenMonths": sorted(months), "runout": runout,
"verdict": "WARN(soft) 经营账对买量预算敏感" if warn else "PASS(soft) 经营账对买量预算不敏感"}
with open(os.path.join(args.out, "o14_monthly_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"baseBreakEvenMonth": base["breakEvenMonth"], "cases": cases,
"distinctBreakEvenMonths": sorted(months), "runout": runout,
"verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
跑 /opt/homebrew/bin/python3 sim/o14_monthly_bench.py --config config/ --out out/,结果如下(退出码仍为 0):
| 买量预算 | 转正月份 | 年度利润(分) | 现金最低点(分) | 现金断裂 |
|---|---|---|---|---|
| −10% | 第 3 月 | 71100000 | 6400000 | 无 |
| 基准 | 第 4 月 | 62800000 | 4200000 | 无 |
| +10% | 第 4 月 | 54500000 | 1300000 | 无 |
B 基准给出的结论是 WARN(soft):转正月份对买量预算敏感——预算降 10%,转正提前到第 3 月;预算升 10%,虽然转正月份不变,但现金最低点被压到 1300000 分(约 1.3 万元),几乎贴着零线。这解释了一个反直觉的纪律:加投不是越多越好。每多投 10% 买量,年度利润少约 830 万分,现金缓冲薄一层;投到某个点,现金低点就会击穿期初现金、引发断裂。所以「放量」必须先看现金最低点,而不是先看收入曲线——这也是 14.3 要据此做「止损还是加投」决策的原因。
X 层:真实扣点、账期与成本结构不可复现
上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的平台综合扣点(分成比例、渠道费、代扣税会叠加,且随渠道与品类不同);
- 真实的结算账期与打款节奏(节假日顺延、对账差异、风险金暂扣都会挪动到账日,见 12.2);
- 真实的成本结构(工资、服务器、买量的付款时点未必与收入对齐,实际现金低点可能更低)。
正确的用法是把这张月度账当预算与预警:模型给出「按当前节奏,第几月转正、现金最低点在哪」的基准线,每月用真实账本回填,一旦实际现金低于模型低点,就是该收缩买量的信号。
自检表
| 检查项 | 合格标准 |
|---|---|
| 两张表齐 | 利润表(营业利润)与现金流量表(累计现金)都算 |
| 时点差显式 | 收入延后到账、成本当月支付,不混为一谈 |
| 转正月份 | 给出 breakEvenMonth,不只看全年合计 |
| 现金最低点 | 给出 cashLowFen 与月份,并判断裂月份 |
| 单位统一 | 金额用「分」、比例用千分比,无浮点 |
| 敏感度 | 对买量预算做 ±10% 扰动,量化转正与现金变化 |
| 边界清晰 | 真实扣点与账期标 X,不写成「实测」 |
月度账告诉你「这门生意每个月能不能养活自己」。但账算出来只是第一步——真正难的是每个月据此做决策:哪些渠道该停、哪些该加、加多少。下一节把 14.1 的单位账与本节月度账合成一张决策表:止损与再投入。
阅读导航:上一节:14.1 单用户全链路账:CAC → ARPDAU → LTV → 回本 · 下一节:14.3 止损与再投入决策 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。