12.1 活动模板与奖励预算

活动是运营最常用的抓手,但「做个活动」背后是一套模板加一份奖励预算。本节把四类活动模板(排行榜赛 / 收集兑换 / 每日签到 / 回归返场)写成配置,用 S 层脚本算人均奖励、发放总量、净通胀与预算超支,再算活动 ROI,把「奖励发多少才不把经济搞崩」变成能跑出来的数字。

站内有两个容易和本章撞车的地方,先把分工说清。小游戏服务平台专题 那 25 篇是做小游戏服务平台——怎么搭一个让开发者上传、分发、变现的 SaaS 平台;本章恰好相反,是在别人搭好的平台上做一款游戏的运营:不关心平台怎么建,只关心我这一个游戏的活动怎么排、奖励怎么发。H5 游戏产业全景 那篇是产业综述口径,讲这条赛道整体怎么长、钱从哪儿流;本章落到一款具体游戏的运营动作上,颗粒度完全不同。还要划清本章边界:变现结构是第 1–4 章、留存漏斗是第 7 章、版本列车与生命周期退场是第 13 章;本章只回答一个问题——活动怎么模板化、奖励怎么定量。

活动不是「灵感」,是「模板 + 预算」

小团队做活动最容易犯的错,是每次当全新创意从零设计:这次排行榜、下次收集、再下次签到,每套都要重新写需求、重新配表、重新压测。真正可持续的做法是把活动抽象成有限的几套模板,每次只换皮、换数值、换排期。模板固定下来之后,运营的边际成本才会下降,core/ 里也才有稳定的配置结构可以驱动。

模板之外还有一件更硬的事:奖励预算。活动几乎必然「赚钱」——发的是虚拟币,成本接近零,所以活动 ROI 天然很高,风险根本不在成本,而在通胀:发放出去的币如果远超回收,游戏内的经济会迅速贬值,之后所有定价、所有内购都会失锚。所以本章的核心不是「活动能不能赚」,而是「这次活动发多少币、回收多少、净通胀多少、有没有超预算」。

四类活动模板

《合成小岛》的活动收敛成四类,各自解决不同的运营目标。四类的差别用三个参数就能刻出来——覆盖人数、人均奖励、发放次数倍数:

  • 排行榜赛(ranked):限时合成赛,按分数排名发奖。目标是拉高峰值活跃,奖励集中在头部,参与奖兜底。
  • 收集兑换(collection):限定主题的收集与兑换。目标是拉长单次会话、提升中期留存,奖励按完成度分档。
  • 每日签到(habit):每天登录领小奖。目标是养成登录习惯,单次奖励极低但发放次数很多(一个月签到 30 次),是通胀的主要来源。
  • 回归返场(winback):给流失用户的回归礼包。目标是召回,人均奖励最高、覆盖人数最少。三个参数一乘,发放总量的量级就出来了——这正是预算要卡住的东西。

D 层:把模板和预算写成配置

下面这份是 D 层(静态层)配置:events 是四套模板,每套声明时长、参与人数、日活跃指数曲线、奖励档位(档位覆盖比例 sharePermille 之和 = 1000)、人均回收、发放次数倍数 grantMultiplierPermille 与奖励预算上限 budgetCapCoins;economy 给基准 DAU、基准 ARPDAU 与「每千枚币的机会成本」。全整数,禁浮点。

{
  "version": 1,
  "series": "《小游戏开发实战》",
  "volume": "卷三《上线变现与长线运营》",
  "game": "merge-island",
  "observedAt": "2026-10",
  "seed": 20261012,
  "note": "活动模板与奖励预算配置(D 层)。events 是四套活动模板(排行榜赛 / 收集兑换 / 每日签到 / 回归返场),每套声明时长、参与人数、日活跃指数曲线、奖励档位(档位覆盖比例 sharePermille 之和 = 1000)、人均回收、发放次数倍数 grantMultiplierPermille 与奖励预算上限 budgetCapCoins;economy 给基准 DAU、基准 ARPDAU 与「每千枚币的机会成本」。奖励预算 = 发放总量上限;净通胀 = 发放 - 回收。全整数,禁浮点。participants / dailyIndexPermille / uplift 均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。",
  "units": {
    "sharePermille": "该奖励档位覆盖的参与者比例,同一活动各档之和 = 1000",
    "rewardCoins": "该档位发放的岛屿币数量",
    "grantMultiplierPermille": "每名参与者平均被发放的次数 ×1000(签到类 > 1000)",
    "dailyIndexPermille": "以首日为 1000 的日活跃指数(相对量级,非绝对人数)",
    "coinOpportunityCostPerMilleCents": "每千枚岛屿币折算的机会成本(分)"
  },
  "economy": {
    "baseDau": 12000,
    "baseArpdauCents": 12,
    "coinOpportunityCostPerMilleCents": 100
  },
  "events": [
    {
      "id": "merge-race",
      "name": "限时合成赛",
      "template": "ranked",
      "days": 7,
      "participants": 4200,
      "dailyIndexPermille": [1000, 820, 690, 610, 560, 520, 470],
      "grantMultiplierPermille": 1000,
      "tiers": [
        { "band": "前 1%", "sharePermille": 10, "rewardCoins": 800 },
        { "band": "前 10%", "sharePermille": 90, "rewardCoins": 300 },
        { "band": "前 50%", "sharePermille": 400, "rewardCoins": 80 },
        { "band": "参与奖", "sharePermille": 500, "rewardCoins": 20 }
      ],
      "sinkCoinsPerParticipant": 60,
      "budgetCapCoins": 500000,
      "uplift": { "dauUpliftPermille": 80, "arpdauUpliftPermille": 60 }
    },
    {
      "id": "collection",
      "name": "海岛收集兑换",
      "template": "collection",
      "days": 14,
      "participants": 6500,
      "dailyIndexPermille": [700, 760, 800, 820, 810, 780, 760, 740, 720, 700, 680, 650, 620, 580],
      "grantMultiplierPermille": 1000,
      "tiers": [
        { "band": "集齐全套", "sharePermille": 10, "rewardCoins": 500 },
        { "band": "集齐过半", "sharePermille": 190, "rewardCoins": 120 },
        { "band": "零星收集", "sharePermille": 800, "rewardCoins": 30 }
      ],
      "sinkCoinsPerParticipant": 40,
      "budgetCapCoins": 600000,
      "uplift": { "dauUpliftPermille": 50, "arpdauUpliftPermille": 40 }
    },
    {
      "id": "checkin",
      "name": "每日签到",
      "template": "habit",
      "days": 30,
      "participants": 9000,
      "dailyIndexPermille": [1000, 900, 840, 800, 780, 760, 740, 720, 700, 690, 680, 670, 660, 650, 640, 630, 620, 610, 600, 590, 580, 570, 560, 550, 540, 530, 520, 510, 500, 490],
      "grantMultiplierPermille": 12000,
      "tiers": [
        { "band": "每次签到", "sharePermille": 1000, "rewardCoins": 5 }
      ],
      "sinkCoinsPerParticipant": 0,
      "budgetCapCoins": 400000,
      "uplift": { "dauUpliftPermille": 120, "arpdauUpliftPermille": 20 }
    },
    {
      "id": "winback",
      "name": "回归返场礼",
      "template": "winback",
      "days": 10,
      "participants": 1500,
      "dailyIndexPermille": [1000, 950, 900, 860, 830, 800, 780, 760, 740, 720],
      "grantMultiplierPermille": 1000,
      "tiers": [
        { "band": "回归礼包", "sharePermille": 1000, "rewardCoins": 200 }
      ],
      "sinkCoinsPerParticipant": 150,
      "budgetCapCoins": 400000,
      "uplift": { "dauUpliftPermille": 200, "arpdauUpliftPermille": 150 }
    }
  ]
}

S 层:奖励发放与预算仿真

下面这段是 S 层脚本:读上面这份配置,对每套模板算人均奖励(档位加权)、发放总量、净通胀、预算是否超支,以及活动 ROI(增量收入 ÷ 奖励机会成本)。产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/l12_event.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l12_event.py --config config/ --out out/
"""活动模板与奖励预算仿真:读 config/l12_event.json。

对每套活动模板算四件事:
1) 人均奖励 = Σ(sharePermille * rewardCoins) // 1000(档位加权);
2) 发放总量 grossCoins = participants * 人均奖励 * grantMultiplierPermille // 1000;
3) 净通胀 netInflation = grossCoins - 回收(participants * sinkCoinsPerParticipant);
4) 预算校验 grossCoins <= budgetCapCoins,并算活动 ROI:
   rewardCostCents = grossCoins * coinOpportunityCostPerMilleCents // 1000;
   upliftRevenueCents = baseDau * days * (uplift 后的 ARPDAU - 基准 ARPDAU)。
产出:out/l12_event.json + .csv + .svg。全整数地板除,禁浮点。participants / uplift 均为示例
假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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


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


def per_participant_reward(tiers: list) -> int:
    return sum(t["sharePermille"] * t["rewardCoins"] for t in tiers) // 1000


def uplift_arpdau(base_cents: int, uplift: dict) -> int:
    return base_cents * (1000 + uplift["dauUpliftPermille"]) * (1000 + uplift["arpdauUpliftPermille"]) // 1000000


def run_event(ev: dict, eco: dict) -> dict:
    per = per_participant_reward(ev["tiers"])
    gross = ev["participants"] * per * ev["grantMultiplierPermille"] // 1000
    sink = ev["participants"] * ev["sinkCoinsPerParticipant"]
    net = gross - sink
    cap = ev["budgetCapCoins"]
    cost_cents = gross * eco["coinOpportunityCostPerMilleCents"] // 1000
    base_arpdau = eco["baseArpdauCents"]
    new_arpdau = uplift_arpdau(base_arpdau, ev["uplift"])
    uplift_cents = eco["baseDau"] * ev["days"] * (new_arpdau - base_arpdau)
    roi = uplift_cents * 1000 // cost_cents if cost_cents else 0
    return {
        "id": ev["id"], "name": ev["name"], "template": ev["template"], "days": ev["days"],
        "participants": ev["participants"], "perParticipantCoins": per,
        "grantMultiplierPermille": ev["grantMultiplierPermille"],
        "grossCoins": gross, "sinkCoins": sink, "netInflationCoins": net,
        "budgetCapCoins": cap, "overBudget": gross > cap,
        "rewardCostCents": cost_cents, "upliftRevenueCents": uplift_cents,
        "upliftedArpdauCents": new_arpdau, "roiPermille": roi,
        "peakDailyIndexPermille": max(ev["dailyIndexPermille"]),
        "dailyIndexPermille": ev["dailyIndexPermille"],
    }


def draw_svg(path: str, rows: list, cfg: dict) -> int:
    W, ROW, TOP = 760, 42, 74
    H = TOP + ROW * len(rows) + 150
    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": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = \
        "《合成小岛》活动奖励发放 vs 预算上限(示例参数,相对量级)"
    ET.SubElement(svg, "text", {"x": "16", "y": "48", "font-size": "12", "fill": "#6b7280"}).text = \
        "蓝=发放总量  红=预算上限  橙点=净通胀(发放-回收)"
    maxv = max(max(r["grossCoins"], r["budgetCapCoins"]) for r in rows)
    L, BW = 210, 440
    for i, r in enumerate(rows):
        y = TOP + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 14), "font-size": "12", "fill": "#374151"}).text = r["name"]
        g = BW * r["grossCoins"] // maxv
        c = BW * r["budgetCapCoins"] // maxv
        ET.SubElement(svg, "rect", {"x": str(L), "y": str(y), "width": str(max(g, 2)), "height": "14",
                                    "rx": "3", "fill": "#2563eb" if not r["overBudget"] else "#dc2626"})
        ET.SubElement(svg, "rect", {"x": str(L), "y": str(y + 16), "width": str(max(c, 2)), "height": "8",
                                    "rx": "2", "fill": "#fca5a5"})
        ET.SubElement(svg, "text", {"x": str(L + max(g, c) + 8), "y": str(y + 13), "font-size": "11",
                                    "fill": "#6b7280"}).text = f"{r['grossCoins']} / 上限 {r['budgetCapCoins']}"
    # 日活跃指数曲线(各活动归一叠画)
    py0 = TOP + ROW * len(rows) + 34
    ET.SubElement(svg, "text", {"x": "16", "y": str(py0 - 12), "font-size": "12", "fill": "#374151"}).text = \
        "各活动日活跃指数曲线(首日 = 1000,相对量级)"
    colors = ["#2563eb", "#10b981", "#f59e0b", "#8b5cf6"]
    maxn = max(len(r["dailyIndexPermille"]) for r in rows)
    px = lambda i, n: 60 + (W - 90) * i // max(1, n - 1)
    py = lambda v: py0 + 100 - 90 * v // 1000
    for k, r in enumerate(rows):
        d = r["dailyIndexPermille"]
        pts = " ".join(f"{px(i, len(d))},{py(v)}" for i, v in enumerate(d))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": colors[k % 4], "stroke-width": "2"})
    os.makedirs(os.path.dirname(path), exist_ok=True)
    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)
    eco = cfg["economy"]
    rows = [run_event(ev, eco) for ev in cfg["events"]]
    total_gross = sum(r["grossCoins"] for r in rows)
    total_sink = sum(r["sinkCoins"] for r in rows)
    over = [r["id"] for r in rows if r["overBudget"]]
    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l12_event.svg"), rows, cfg)
    with open(os.path.join(a.out, "l12_event.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "template", "days", "participants", "perParticipantCoins",
                    "grossCoins", "sinkCoins", "netInflationCoins", "budgetCapCoins", "overBudget",
                    "rewardCostCents", "upliftRevenueCents", "roiPermille"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["template"], r["days"], r["participants"],
                        r["perParticipantCoins"], r["grossCoins"], r["sinkCoins"], r["netInflationCoins"],
                        r["budgetCapCoins"], r["overBudget"], r["rewardCostCents"],
                        r["upliftRevenueCents"], r["roiPermille"]])
    report = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
              "eventCount": len(rows), "totalGrossCoins": total_gross, "totalSinkCoins": total_sink,
              "totalNetInflationCoins": total_gross - total_sink, "overBudget": over,
              "events": rows, "svgBytes": sb}
    with open(os.path.join(a.out, "l12_event.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({"eventCount": len(rows), "overBudget": over,
                      "totalGrossCoins": total_gross, "totalNetInflationCoins": total_gross - total_sink,
                      "perEvent": {r["id"]: {"perParticipantCoins": r["perParticipantCoins"],
                                             "grossCoins": r["grossCoins"], "netInflationCoins": r["netInflationCoins"],
                                             "budgetCapCoins": r["budgetCapCoins"], "overBudget": r["overBudget"],
                                             "roiPermille": r["roiPermille"]} for r in rows},
                      "svgBytes": sb}, ensure_ascii=False, indent=2))
    return 0


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

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

{
  "eventCount": 4,
  "overBudget": [
    "checkin"
  ],
  "totalGrossCoins": 1494900,
  "totalNetInflationCoins": 757900,
  "perEvent": {
    "merge-race": {
      "perParticipantCoins": 77,
      "grossCoins": 323400,
      "netInflationCoins": 71400,
      "budgetCapCoins": 500000,
      "overBudget": false,
      "roiPermille": 2597
    },
    "collection": {
      "perParticipantCoins": 51,
      "grossCoins": 331500,
      "netInflationCoins": 71500,
      "budgetCapCoins": 600000,
      "overBudget": false,
      "roiPermille": 5067
    },
    "checkin": {
      "perParticipantCoins": 5,
      "grossCoins": 540000,
      "netInflationCoins": 540000,
      "budgetCapCoins": 400000,
      "overBudget": true,
      "roiPermille": 6666
    },
    "winback": {
      "perParticipantCoins": 200,
      "grossCoins": 300000,
      "netInflationCoins": 75000,
      "budgetCapCoins": 400000,
      "overBudget": false,
      "roiPermille": 16000
    }
  },
  "svgBytes": 2491
}

读这份输出

超支的那一个恰好是最不起眼的活动。checkin 人均只有 5 币,看起来最省,但它 grantMultiplierPermille 是 12000——一个月平均发 12 次,9000 人一乘,发放总量 540000 币,直接顶穿 400000 的预算上限,净通胀 540000(因为它没有回收机制)。这说明一个反直觉的结论:单次奖励低不等于总发放低,次数才是通胀的隐藏乘数。排行榜赛人均 77 币听着多,但只发一次、4200 人,总量 323400,反而在预算内。

四类活动的净通胀量级完全不同。merge-race 净通胀 71400、collection 71500——它们都有回收(人均 60 / 40 币),发放的一部分被消耗掉。winback 净通胀 75000,靠人均 150 币的高回收把 200 币的发放压下来一半。唯独 checkin 回收为 0,发多少全是净通胀。所以做活动预算时,回收机制和发放上限同等重要:没有回收的活动,预算上限就是唯一的安全阀。

ROI 全线很高,这恰恰说明成本不是重点。winback 的 ROI 最高(16000‰,即约 16 倍),因为它召回的是已经流失的高价值用户,增量最大;merge-race 最低(2597‰),因为它提升的多是已有活跃。但这些倍数都远大于 1——活动几乎必然「划算」,真正要管的从来不是 ROI,而是通胀。把 ROI 当活动成败的标准,会让人不断加码奖励,最后把经济搞崩。

小结与下一步

这一节把活动做成了「模板 + 预算」:四类模板用覆盖人数、人均奖励、发放次数三个参数刻画;奖励预算卡的是发放总量与净通胀,不是 ROI。三件事记住就够——**单次奖励低不代表总发放低,次数是隐藏乘数;没有回收的活动,预算上限是唯一安全阀;活动 ROI 天然很高,通胀才是真风险。**下一节把单个活动放大到一整个赛季:赛季怎么分阶段、节奏怎么维持、阶梯奖励怎么定。

阅读导航:上一节:目录 · 下一节:赛季结构与节奏 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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