6.1 自然增长与裂变机制

把裂变从「社交设计」搬进「增长模型」:用一个带留存与买量注入的 DAU 递推,算清自然量占比与有效获客成本,给出可跑的 Python 仿真、同口径的 TypeScript 内核与 4 项单元测试,验证 K 越高、自然量占比越高、被摊薄的获客成本越低。

站内已有一篇H5 游戏产业全景 ,那篇是产业综述口径——把 H5 / 小游戏这条赛道的平台、品类、变现、买量横向摊开讲了一遍;本节给的是可跑、可断言的增长模型:把「裂变」从一个社交设计指标,变成一个能算出自然量占比与有效获客成本的 DAU 递推。区别在写法:那篇回答「这个行业长什么样」,本节回答「我这盘用户是怎么涨起来的、每一分买量钱被摊薄了多少」。

上一章把「买量」这条付费增长通道讲透了——素材、出价、渠道机制。但一款小游戏的用户从来不是只靠买量堆出来的。绝大多数产品在买量之外,还有一条不花钱但更难控制的增长通道:自然增长。

自然增长不是「免费的买量」

自然增长常被误解成「不花钱的买量」,但两者在机制上完全不同。买量是外部注入:你花多少钱,平台给你多少曝光,可预算、可开关、可线性放大。自然增长是内部放大:玩家因为好玩而留下、因为想炫耀而分享、因为被安利而进来,它不随预算线性变化,而是跟着产品本身的传播系数走。

更关键的是,自然增长不独立于产品。买量拉来的用户质量差,只影响这批人自己的留存;自然增长的质量差,会反过来削弱传播系数——被安利进来的人体验不好,就不会再安利别人。所以自然增长不是一条可以「补预算」的通道,它是产品力的直接读数。这一节要算的是:当一款游戏同时有买量和自然增长两条通道时,自然增长到底摊薄了多少获客成本。

自然增长在模型里可以拆成三项:社交裂变(老玩家邀请 / 分享带来的新用户,由传播系数 K 与裂变周期决定)、口碑与自然搜索(玩家主动搜索、应用商店自然浏览,每天大致恒定)、平台自然推荐(平台算法给的免费曝光)。第三项是下一节的主题,本节先不放进模型。

D 层:把模型参数落成配置

下面这份是 D 层(静态层)产物,全整数、没有任何浮点。它把模型的全部假设固定下来,Python 仿真、TypeScript 内核、浏览器原型三处读同一份。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "自然增长与裂变增长模型:DAU 按「留存‰ × 上日 DAU + 当日新增」递推,当日新增 = 买量 + 裂变 + 口碑。裂变新增由「上一日新增流」放大而来(amplPermille = kPermille // viralCycleDays),体现裂变靠新用户滚新用户、而非靠存量 DAU 反复复制;K 与裂变周期共同决定放大速度与自然量占比。全整数,禁浮点。真实 K / 留存 / 口碑量级需真实业务环境,命令行不可复现(X)。",
  "seedDau": 2000,
  "horizonDays": 30,
  "retentionPermille": 900,
  "paidNewPerDay": 800,
  "paidCacCent": 120,
  "wordOfMouthPerDay": 40,
  "kPermille": 800,
  "viralCycleDays": 3,
  "kScenariosPermille": [0, 300, 800, 1500, 2400]
}

配置里有三类参数:注入参数(paidNewPerDay 每天买量新增、paidCacCent 买量单价、wordOfMouthPerDay 口碑新增)、传播参数(kPermille 传播系数、viralCycleDays 裂变周期)、递推参数(seedDau 起始 DAU、horizonDays 观察天数、retentionPermille 日留存)。kScenariosPermille 是要对照的几档 K。

S 层:把 K 放进 DAU 模型

下面这段是 S 层仿真脚本,读 config/l06_growth.json,对每一档 K 跑一遍 DAU 递推,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l06_organic_growth.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l06_organic_growth.py --config config/ --out out/
"""自然增长与裂变增长模型:读 config/l06_growth.json。
裂变放大的是「上一日新增流」而非存量 DAU:ampl = kPermille // viralCycleDays,
viralNew = 上一日新增 × ampl // 1000;DAU = 上日 DAU × retentionPermille // 1000 + 当日新增。
输出自然量占比(organicSharePermille)与有效获客成本(买量花费 // 全部新增)。
全整数地板除,无浮点。真实 K / 留存 / 口碑量级需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


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


def simulate(cfg: dict, k: int) -> list[dict]:
    ampl = k // cfg["viralCycleDays"]
    dau, prev, cum, rows = cfg["seedDau"], 0, 0, []
    for day in range(1, cfg["horizonDays"] + 1):
        viral = prev * ampl // 1000
        total = cfg["paidNewPerDay"] + viral + cfg["wordOfMouthPerDay"]
        organic = viral + cfg["wordOfMouthPerDay"]
        dau = dau * cfg["retentionPermille"] // 1000 + total
        cum += total
        prev = total
        rows.append({"day": day, "dau": dau, "totalNew": total, "viralNew": viral,
                     "organicNew": organic, "cumNew": cum})
    return rows


def draw_svg(path: str, series: list[dict]) -> int:
    W, H, pl, pt, pb = 720, 360, 74, 34, 150
    days = len(series[0]["curve"])
    hi = max(p["dau"] for s in series for p in s["curve"]) or 1
    iw, ih = W - pl - 160, H - pt - pb
    cols = ["#94a3b8", "#60a5fa", "#22c55e", "#f59e0b", "#dc2626"]
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": str(pl), "y": "22", "font-size": "14",
                                "fill": "#111827"}).text = "不同 K 值下的 DAU 曲线(留存 900‰ + 买量注入)"
    for x1, y1, x2, y2 in [(pl, pt + ih, pl + iw, pt + ih), (pl, pt, pl, pt + ih)]:
        ET.SubElement(svg, "line", {"x1": str(x1), "y1": str(y1), "x2": str(x2),
                                    "y2": str(y2), "stroke": "#9ca3af"})
    for i, s in enumerate(series):
        pts = [f'{pl + iw * (p["day"] - 1) // (days - 1)},{pt + ih - ih * p["dau"] // hi}'
               for p in s["curve"]]
        col = cols[i % len(cols)]
        ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none",
                                        "stroke": col, "stroke-width": "2"})
        ly = pt + 14 + i * 20
        ET.SubElement(svg, "rect", {"x": str(W - 148), "y": str(ly - 9), "width": "10",
                                    "height": "10", "fill": col})
        ET.SubElement(svg, "text", {"x": str(W - 132), "y": str(ly), "font-size": "11",
                                    "fill": "#374151"}).text = f'K={s["kPermille"]}‰ 末DAU{s["finalDau"]}'
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    spend = cfg["paidNewPerDay"] * cfg["paidCacCent"] * cfg["horizonDays"]
    series = []
    for k in cfg["kScenariosPermille"]:
        curve = simulate(cfg, k)
        organic, cum = sum(p["organicNew"] for p in curve), curve[-1]["cumNew"]
        series.append({"kPermille": k, "curve": curve, "finalDau": curve[-1]["dau"], "cumNew": cum,
                       "organicSharePermille": organic * 1000 // cum if cum else 0,
                       "effectiveCacCent": spend // cum if cum else 0})
    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "l06_organic_growth.svg"), series)
    with open(os.path.join(a.out, "l06_organic_growth.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["kPermille", "day", "dau", "totalNew", "viralNew", "organicNew", "cumNew"])
        for s in series:
            for p in s["curve"]:
                w.writerow([s["kPermille"], p["day"], p["dau"], p["totalNew"],
                            p["viralNew"], p["organicNew"], p["cumNew"]])
    base = next(s for s in series if s["kPermille"] == cfg["kPermille"])
    noviral = next(s for s in series if s["kPermille"] == 0)
    keys = ("finalDau", "cumNew", "organicSharePermille", "effectiveCacCent")
    rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "seedDau": cfg["seedDau"],
           "horizonDays": cfg["horizonDays"], "retentionPermille": cfg["retentionPermille"],
           "paidNewPerDay": cfg["paidNewPerDay"], "paidCacCent": cfg["paidCacCent"],
           "wordOfMouthPerDay": cfg["wordOfMouthPerDay"], "viralCycleDays": cfg["viralCycleDays"],
           "baselineKPermille": cfg["kPermille"], "baseline": {x: base[x] for x in keys},
           "noViralEffectiveCacCent": noviral["effectiveCacCent"],
           "scenarios": [{"kPermille": s["kPermille"], **{x: s[x] for x in keys}} for s in series],
           "csv": os.path.join(a.out, "l06_organic_growth.csv"),
           "svg": os.path.join(a.out, "l06_organic_growth.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "l06_organic_growth.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"baselineKPermille": cfg["kPermille"], "baseline": rep["baseline"],
                      "noViralEffectiveCacCent": rep["noViralEffectiveCacCent"],
                      "scenarios": [(s["kPermille"], s["finalDau"], s["organicSharePermille"],
                                     s["effectiveCacCent"]) for s in series]}, ensure_ascii=False))
    return 0


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

模型只有四行核心逻辑:裂变放大系数 ampl = kPermille // viralCycleDays;当日裂变新增 viralNew = 上一日新增 × ampl // 1000;当日总新增 totalNew = 买量 + 裂变 + 口碑;DAU = 上日 DAU × retentionPermille // 1000 + totalNew。

这里有一个关键的建模选择:裂变放大的对象是「上一日的新增流」,而不是「存量 DAU」。如果按存量 DAU 放大,那么 2000 个老用户每天都会各自复制 14% 出来,DAU 会指数爆炸到不可信——但现实中裂变靠的是新用户滚新用户:老玩家早把好友邀请完了,不会再产出新邀请。用新增流做载体,模型就自然收敛了。

在本机跑它,输出如下(同机相对量级,绝对数值不可移植):

{"baselineKPermille": 800, "baseline": {"finalDau": 11016, "cumNew": 33904, "organicSharePermille": 292, "effectiveCacCent": 84}, "noViralEffectiveCacCent": 114, "scenarios": [[0, 8124, 47, 114], [300, 9009, 139, 103], [800, 11016, 292, 84], [1500, 16070, 507, 59], [2400, 38904, 780, 26]]}

基线 K = 800‰(裂变周期 3 天,放大系数 266‰)下,30 天累计新增 33904 人,其中自然量占比 29.2%;买量每天固定花 800 × 1.20 元,摊到全部新增上,有效获客成本只有 0.84 元,比名义的 1.20 元低了 30%。五档 K 把这条规律摊开:K = 0(无裂变)时自然量占比只有 4.7%、有效 CAC 1.14 元;K 抬到 2400‰ 时自然量占比 78.0%、有效 CAC 降到 0.26 元。

P 层:同一份 config 的内核

仿真跑通了,但它只是 Python 里的一段验证。要让游戏真正用上这个模型——比如在运营后台实时算「今天的有效 CAC」——还需要一份能在客户端 / 服务端跑的 TypeScript 内核。下面这段是 P 层产物 core/src/l06/growth.ts,与 Python 脚本同一份 config、同一套整数地板除,因此逐位一致。全整数、无 Math.random()。

// file: core/src/l06/growth.ts
// tier: P
// verify: node --test 'core/test/l06_*.test.ts'
// 《合成小岛》自然增长内核:把裂变放进带留存与买量注入的 DAU 递推里。
// 与 sim/l06_organic_growth.py 同一份 config、同一套整数地板除,逐位一致。
// 全整数、无 Math.random()、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface GrowthConfig {
  seedDau: number; horizonDays: number; retentionPermille: number;
  paidNewPerDay: number; paidCacCent: number; wordOfMouthPerDay: number;
  kPermille: number; viralCycleDays: number;
}
export interface GrowthDay {
  day: number; dau: number; totalNew: number; viralNew: number; organicNew: number; cumNew: number;
}
export interface GrowthSummary {
  finalDau: number; cumNew: number; organicSharePermille: number; effectiveCacCent: number;
}

const fdiv = (a: number, b: number): number => Math.floor(a / b);

/** 裂变放大系数:K 摊到一个裂变周期上(整数地板除)。 */
export const amplPermille = (kPermille: number, viralCycleDays: number): number =>
  fdiv(kPermille, viralCycleDays);

/** 逐日递推。裂变放大的是「上一日新增流」,不是存量 DAU——避免把老用户反复复制。 */
export function simulateGrowth(cfg: GrowthConfig, kPermille: number): GrowthDay[] {
  const ampl = amplPermille(kPermille, cfg.viralCycleDays);
  let dau = cfg.seedDau, prev = 0, cum = 0;
  const rows: GrowthDay[] = [];
  for (let d = 1; d <= cfg.horizonDays; d += 1) {
    const viralNew = fdiv(prev * ampl, 1000);
    const totalNew = cfg.paidNewPerDay + viralNew + cfg.wordOfMouthPerDay;
    dau = fdiv(dau * cfg.retentionPermille, 1000) + totalNew;
    cum += totalNew;
    prev = totalNew;
    rows.push({ day: d, dau, totalNew, viralNew, organicNew: viralNew + cfg.wordOfMouthPerDay, cumNew: cum });
  }
  return rows;
}

/** 汇总:自然量占比(‰)与有效获客成本(分)= 买量花费 // 全部新增。 */
export function summarizeGrowth(rows: GrowthDay[], cfg: GrowthConfig): GrowthSummary {
  const last = rows[rows.length - 1];
  const organic = rows.reduce((s, r) => s + r.organicNew, 0);
  const spend = cfg.paidNewPerDay * cfg.paidCacCent * cfg.horizonDays;
  return {
    finalDau: last.dau, cumNew: last.cumNew,
    organicSharePermille: last.cumNew ? fdiv(organic * 1000, last.cumNew) : 0,
    effectiveCacCent: last.cumNew ? fdiv(spend, last.cumNew) : 0,
  };
}

P 层:把口径锁进单元测试

内核和仿真一致不能靠「看着像」,要靠断言。下面这段是 P 层测试文件,直接读 config/l06_growth.json,把四个关键性质锁进测试。

// file: core/test/l06_growth.test.ts
// tier: P
// verify: node --test 'core/test/l06_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { amplPermille, simulateGrowth, summarizeGrowth } from "../src/l06/growth.ts";

const cfg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "config", "l06_growth.json"), "utf-8"));

test("裂变放大系数 = K // 裂变周期(整数地板除)", () => {
  assert.equal(amplPermille(800, 3), 266);
  assert.equal(amplPermille(1, 3), 0);
});

test("第一日无裂变:只有买量 + 口碑", () => {
  const rows = simulateGrowth(cfg, cfg.kPermille);
  assert.equal(rows[0].viralNew, 0);
  assert.equal(rows[0].totalNew, cfg.paidNewPerDay + cfg.wordOfMouthPerDay);
});

test("K 越大:自然量占比越高、有效获客成本越低", () => {
  const a = summarizeGrowth(simulateGrowth(cfg, 300), cfg);
  const b = summarizeGrowth(simulateGrowth(cfg, cfg.kPermille), cfg);
  const c = summarizeGrowth(simulateGrowth(cfg, 2400), cfg);
  assert.ok(a.organicSharePermille < b.organicSharePermille && b.organicSharePermille < c.organicSharePermille);
  assert.ok(a.effectiveCacCent > b.effectiveCacCent && b.effectiveCacCent > c.effectiveCacCent);
});

test("与 Python 仿真同口径 + 无隐藏随机", () => {
  const s = summarizeGrowth(simulateGrowth(cfg, cfg.kPermille), cfg);
  assert.deepEqual([s.finalDau, s.cumNew, s.effectiveCacCent], [11016, 33904, 84]);
  assert.deepEqual(simulateGrowth(cfg, cfg.kPermille), simulateGrowth(cfg, cfg.kPermille));
});

本机跑它,4 项全部通过(同机相对量级,耗时不可移植):

✔ 裂变放大系数 = K // 裂变周期(整数地板除)
✔ 第一日无裂变:只有买量 + 口碑
✔ K 越大:自然量占比越高、有效获客成本越低
✔ 与 Python 仿真同口径 + 无隐藏随机
ℹ tests 4
ℹ pass 4
ℹ fail 0

最后一条最关键:finalDau / cumNew / effectiveCacCent 三项与 Python 仿真的输出逐位一致(11016 / 33904 / 84)。这不是巧合,而是「同一份 config + 同一套整数地板除」的必然结果——这正是本套反复强调的「一份 config 多种消费」。只要增长率用整数比、除法用地板除,跨语言就不会有第 15 位小数的分歧。

从 47‰ 到 780‰:自然量占比怎么读

K(‰)裂变放大(‰)末日 DAU自然量占比有效 CAC
0081244.7%1.14 元
300100900913.9%1.03 元
8002661101629.2%0.84 元
15005001607050.7%0.59 元
24008003890478.0%0.26 元

读这张表要注意两点。第一,K 的效果不是线性的:K 从 0 到 800,有效 CAC 只从 1.14 降到 0.84;但 K 从 800 到 2400,CAC 直接掉到 0.26。因为裂变是乘积关系,越接近临界点(放大系数逼近 1000‰)越陡。第二,表里没有「平台推荐」这一项——它被刻意留给了下一节,因为它由平台算法决定,不像裂变那样由产品自己控制。

与第 9 章的分工

本节与卷一 9.2 都在算传播系数 K,但问的是两个问题。9.2 问的是「K 本身的分界线在哪」——把 K 从 400‰ 扫到 1800‰,找出 1000‰ 这条衰减 / 自增长的分界,把 K 当成一个独立的研究对象。本节问的是「把 K 放进一盘真实的用户账里,自然量能摊薄多少获客成本」——K 只是模型的一个输入,输出是自然量占比与有效 CAC。前者回答「裂变能不能自我维持」,后者回答「裂变值多少钱」。这也是为什么本节要把买量与留存一起放进模型:脱离留存谈 K,算不出有效 CAC。

小结与下一步

这一节把「自然增长」从一句口号变成了一个能算出数字的模型:裂变、口碑、买量三股流合成每日新增,DAU 按留存递推,输出自然量占比与有效获客成本。Python 仿真、TS 内核、4 项测试三处同口径,K 越高、自然量占比越高、有效 CAC 越低。

但模型里还缺一块最重要的自然增长来源:平台自然推荐。它不像裂变那样由产品控制,也不像买量那样由预算控制,而是由平台的推荐算法决定——平台把免费曝光给谁、给多少,是一套我们只能观察、不能复现的机制。下一节就把它放进对照表,看看「有平台推荐」和「没平台推荐」两种情形下,获客结构差多少。

阅读导航:上一节:目录 · 下一节:平台推荐与流量获取 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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