1.1 小游戏的定义与边界

小游戏不是「缩小版的手机游戏」,而是被包体、加载与宿主能力三重约束重新定义过的一类产品。本篇给出小游戏的可量化边界:一张跨平台能力与包体约束矩阵、一段读配置算首包预算的仿真脚本,以及《合成小岛》在八大平台上的装包结论,并说明为什么包体约束必须在写第一行玩法代码之前就算清楚。

「小游戏」这个词在行业里几乎从不指同一件事。有人说的是一段能在微信里点开就玩的 H5;有人指的是抖音信息流里那条竖屏试玩;也有人指的是 Poki 上那个满屏网页广告的休闲页游。它们共用「免下载」「点开即玩」这几个词,但背后的包体上限、加载路径、宿主 API 是三套完全不同的东西。

本章只解决一件事:把「小游戏」从一个模糊的说法,变成一个可量化的边界。它不碰任何具体玩法——玩法拆解是第 6 章,品类选型是第 3 章,引擎是卷二;这里的产物是一张平台能力与约束矩阵、一段算首包预算的仿真,以及《合成小岛》的装包结论。

先说分工。本站有一篇 H5游戏产业全景 ,那是 H5 产业的全景综述(技术栈、生态、变现、政策、未来五年),覆盖面广但每条都浅;本节给的是小游戏平台的可量化边界与规模口径——不是「H5 有哪些平台」,而是「某个平台的主包上限是多少 MB、它如何反向决定你的玩法能有多大」。本站另有一组 小游戏服务平台专题 (25 篇),那 25 篇讲的是做小游戏服务平台——商业计划书、融资方案、可视化编辑器与低代码创作工具;本套讲的是在平台上做一款游戏——立项、选型、包体、玩法、变现。两套可以并读:先看那 25 篇理解平台方视角,再用本卷理解产品方视角。

什么算小游戏:三重约束,而不是一个品类

「小游戏」不是按玩法定义的品类,而是按运行环境定义的交付形态。一个产品是不是小游戏,取决于它是否同时满足三重约束:

约束内容反向决定什么
包体约束主包 / 总包有明确上限,必须分包或热更素材规格、关卡数量、首屏范围
加载约束首屏加载时长直接决定进入率首包放什么、什么时候加载什么
宿主约束运行在平台沙箱里,无完整 DOM、无任意文件与网络权限渲染方式、API 抽象层、兼容策略

这三重约束里,包体约束是唯一能被一张表算清楚的,也是本节的落点。加载约束是 1.2 的主题,宿主约束是第 4 章与卷二第 10 章的主题。

一个常被忽略的事实:约束是从平台侧施加的,不是产品侧选择的。你在微信上做主包上限 4 MB,不是因为你「想省流量」,而是因为超过就不给过审。所以小游戏设计的第一步不是「我想做什么玩法」,而是「平台允许我一次交付多大」。

平台能力与包体约束矩阵(D 层)

下面这段是 D 层配置:把八个主流平台的主包 / 总包上限、分包数量与十项宿主能力各记一行。数值是按各平台公开文档口径整理的建模输入,标为公开可观察(观察时点 2026-10),不是任何平台后台实测数据,真实上限以各平台最新文档为准。

{
  "version": 1,
  "note": "平台能力与包体约束矩阵。数值按各平台公开文档口径整理,标为公开可观察,观察时点 2026-10;不是任何平台后台实测,真实上限以各平台最新文档为准(不可复现处标 X)。体积单位 MB,全整数,禁浮点。",
  "observedAt": "2026-10",
  "capabilityAxes": [
    { "id": "pay", "name": "内购支付" }, { "id": "rewardAd", "name": "激励视频" },
    { "id": "interstitialAd", "name": "插屏广告" }, { "id": "bannerAd", "name": "Banner" },
    { "id": "socialShare", "name": "社交分享" }, { "id": "friendRank", "name": "好友排行" },
    { "id": "cloudSave", "name": "云存档" }, { "id": "vibrate", "name": "震动反馈" },
    { "id": "screenRecord", "name": "录屏分享" }, { "id": "realtimeMultiplayer", "name": "实时对战" }
  ],
  "platforms": [
    { "id": "wechat", "name": "微信小游戏", "mainPackageLimitMb": 4, "totalPackageLimitMb": 20,
      "maxSubpackages": 30, "capabilities": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] },
    { "id": "douyin", "name": "抖音小游戏", "mainPackageLimitMb": 4, "totalPackageLimitMb": 20,
      "maxSubpackages": 30, "capabilities": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] },
    { "id": "kuaishou", "name": "快手小游戏", "mainPackageLimitMb": 4, "totalPackageLimitMb": 20,
      "maxSubpackages": 20, "capabilities": [1, 1, 1, 1, 1, 1, 1, 1, 0, 0] },
    { "id": "qq", "name": "QQ 小游戏", "mainPackageLimitMb": 4, "totalPackageLimitMb": 20,
      "maxSubpackages": 30, "capabilities": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] },
    { "id": "baidu", "name": "百度小游戏", "mainPackageLimitMb": 4, "totalPackageLimitMb": 20,
      "maxSubpackages": 20, "capabilities": [1, 1, 1, 1, 1, 0, 1, 1, 0, 0] },
    { "id": "alipay", "name": "支付宝小游戏", "mainPackageLimitMb": 4, "totalPackageLimitMb": 20,
      "maxSubpackages": 20, "capabilities": [1, 1, 1, 0, 1, 0, 1, 1, 0, 0] },
    { "id": "poki", "name": "Poki(网页)", "mainPackageLimitMb": 8, "totalPackageLimitMb": 8,
      "maxSubpackages": 0, "capabilities": [0, 1, 1, 1, 0, 0, 0, 0, 0, 0] },
    { "id": "crazygames", "name": "CrazyGames(网页)", "mainPackageLimitMb": 8, "totalPackageLimitMb": 50,
      "maxSubpackages": 0, "capabilities": [0, 1, 1, 1, 0, 0, 0, 0, 0, 0] }
  ],
  "targetGame": {
    "id": "merge-island",
    "name": "《合成小岛》",
    "assets": {
      "coreCodeMb": 2, "bootScreenMb": 1, "coreAtlasMb": 3, "coreAudioMb": 1,
      "extAtlasMb": 14, "extAudioMb": 5, "levelDataMb": 4, "liveOpsMb": 3
    },
    "note": "core* 为首屏必需(引擎运行时、启动图、首屏图集、首屏音效);ext*/levelData/liveOps 为可延后到分包或热更的资源。"
  }
}

从这张表能读出三件对设计有直接影响的事:

  • 国内六家(微信 / 抖音 / 快手 / QQ / 百度 / 支付宝)的主包上限同档,都是 4 MB;差异在能力而非体积——能力项从 10 项(微信、抖音、QQ)递减到 6 项(支付宝)。
  • 两个网页平台(Poki / CrazyGames)没有分包:Poki 主包即总包(8 MB),CrazyGames 允许首包 8 MB、总包 50 MB。这意味着「分包」这套工程手段在国内平台有效、在 Poki 上完全不可用。
  • 能力项里最贵的是「社交」与「实时」:socialShare / friendRank / realtimeMultiplayer 只在少数平台可用,任何依赖它们做核心循环的设计,都等于把自己锁死在少数平台。

首包预算:把「装不下」算成一个数(S 层)

下面这段是 S 层仿真脚本,读 config/ 把上面那张表变成《合成小岛》的逐平台装包结论,产出 JSON + CSV + SVG。模型全整数、无浮点:首屏必需体积 firstScreenMb 减去各平台主包上限就是「首包余量」,为负即首屏已超主包。

#!/usr/bin/env python3
# file: sim/ch01_platform_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch01_platform_budget.py --config config/ --out out/
"""平台约束下的包体预算:读 config/ch01_platforms.json,算《合成小岛》在各平台的
「首包余量 / 分包容量 / 是否装得下」,产出 JSON + CSV + SVG。

模型(全整数 MB,无浮点):
- firstScreenMb = core* 之和:首屏可玩前必须进主包的部分。
- mainHeadroomMb = 主包上限 - firstScreenMb;为负说明首屏已超主包,必须裁剪。
- subCapacityMb = 总包上限 - 主包上限:可延后资源能用的分包容量。
- totalFits = 1 表示 firstScreenMb + deferrableMb <= 总包上限。
纯标准库,图表用 xml.etree 手写 SVG,零依赖。
"""

from __future__ import annotations

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

CORE_KEYS = ("coreCodeMb", "bootScreenMb", "coreAtlasMb", "coreAudioMb")
DEFER_KEYS = ("extAtlasMb", "extAudioMb", "levelDataMb", "liveOpsMb")
COLS = ["platform", "mainPackageLimitMb", "totalPackageLimitMb", "firstScreenMb",
        "deferrableMb", "mainHeadroomMb", "subCapacityMb",
        "subpackageUtilizationPct", "totalFits", "capabilityCount"]


def budget_rows(cfg):
    assets = cfg["targetGame"]["assets"]
    first = sum(assets[k] for k in CORE_KEYS)
    defer = sum(assets[k] for k in DEFER_KEYS)
    rows = []
    for p in cfg["platforms"]:
        main, total = p["mainPackageLimitMb"], p["totalPackageLimitMb"]
        cap = total - main
        rows.append({
            "platform": p["id"], "name": p["name"],
            "mainPackageLimitMb": main, "totalPackageLimitMb": total,
            "firstScreenMb": first, "deferrableMb": defer,
            "mainHeadroomMb": main - first, "subCapacityMb": cap,
            "subpackageUtilizationPct": (defer * 100) // cap if cap > 0 else -1,
            "totalFits": 1 if first + defer <= total else 0,
            "capabilityCount": sum(p["capabilities"]),
        })
    return rows, first, defer


def write_svg(path, rows, first):
    """首包余量条形图(可正可负),零线居中;负值=首屏已超主包。"""
    W, H, PAD, gap = 720, 400, 60, 14
    n = len(rows)
    vals = [r["mainHeadroomMb"] for r in rows]
    vmax, vmin = max(max(vals), 0), min(min(vals), 0)
    span = max(vmax - vmin, 1)
    plot_h, plot_w = H - 2 * PAD - 40, W - 2 * PAD
    zero_y = PAD + 20 + (vmax / span) * plot_h
    bar_w = (plot_w - gap * (n + 1)) / n
    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(PAD), "y": "24", "font-size": "16",
                                "font-family": "sans-serif"}).text = (
        f"各平台首包余量(MB):主包上限 - 首屏必需 {first}MB,负值=首屏已超主包")
    ET.SubElement(svg, "line", {"x1": str(PAD), "y1": f"{zero_y:.1f}", "x2": str(W - PAD),
                                "y2": f"{zero_y:.1f}", "stroke": "#9ca3af"})
    for i, r in enumerate(rows):
        v = r["mainHeadroomMb"]
        x = PAD + gap + i * (bar_w + gap)
        h = abs(v) / span * plot_h
        y = zero_y - h if v >= 0 else zero_y
        ET.SubElement(svg, "rect", {"x": f"{x:.1f}", "y": f"{y:.1f}", "width": f"{bar_w:.1f}",
                                    "height": f"{h:.1f}", "fill": "#2563eb" if v >= 0 else "#dc2626"})
        ty = y - 4 if v >= 0 else y + h + 14
        ET.SubElement(svg, "text", {"x": f"{x + bar_w / 2:.1f}", "y": f"{ty:.1f}", "font-size": "11",
                                    "text-anchor": "middle"}).text = str(v)
        ET.SubElement(svg, "text", {"x": f"{x + bar_w / 2:.1f}", "y": str(H - PAD + 16),
                                    "font-size": "11", "text-anchor": "middle"}).text = r["platform"]
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = json.load(open(os.path.join(args.config, "ch01_platforms.json"), encoding="utf-8"))
    os.makedirs(args.out, exist_ok=True)
    rows, first, defer = budget_rows(cfg)
    with open(os.path.join(args.out, "ch01_platform_budget.csv"), "w",
              encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(COLS)
        for r in rows:
            w.writerow([r[c] for c in COLS])
    svg_bytes = write_svg(os.path.join(args.out, "ch01_platform_budget.svg"), rows, first)
    summary = {
        "version": 1, "source": "sim/ch01_platform_budget.py",
        "targetGame": cfg["targetGame"]["id"],
        "firstScreenMb": first, "deferrableMb": defer, "totalMb": first + defer,
        "platformsOverMainBudget": [r["platform"] for r in rows if r["mainHeadroomMb"] < 0],
        "platformsTotalNotFit": [r["platform"] for r in rows if r["totalFits"] == 0],
        "rows": rows, "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch01_platform_budget.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    print(json.dumps({
        "firstScreenMb": first, "deferrableMb": defer, "totalMb": first + defer,
        "platformsOverMainBudget": summary["platformsOverMainBudget"],
        "platformsTotalNotFit": summary["platformsTotalNotFit"],
        "headroomByPlatform": {r["platform"]: r["mainHeadroomMb"] for r in rows},
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

同机实跑(节选,同机相对量级,绝对数值不可移植):

firstScreenMb: 7   deferrableMb: 26   totalMb: 33
platformsOverMainBudget: [wechat, douyin, kuaishou, qq, baidu, alipay]
platformsTotalNotFit:    [wechat, douyin, kuaishou, qq, baidu, alipay, poki]
headroomByPlatform: {wechat:-3, douyin:-3, kuaishou:-3, qq:-3, baidu:-3, alipay:-3, poki:1, crazygames:1}

读法:负的余量才是这份结论的价值

《合成小岛》的首屏必需体积是 7 MB(2 MB 引擎运行时 + 1 MB 启动图 + 3 MB 首屏图集 + 1 MB 首屏音效),可延后资源是 26 MB,总计 33 MB。把它丢进上面的模型,结论并不好看:

  • 六个国内平台的首包余量全是 -3 MB。也就是说,首屏 7 MB 已经比 4 MB 的主包上限多出 3 MB——还没开始做玩法,包体就已经超了。这不是「优化一下就好」,而是必须在首屏范围、素材规格、渲染方式上做结构性裁剪。
  • 只有 CrazyGames 一个平台的总包装得下 33 MB(总包 50 MB,利用率 61%);Poki 总包只有 8 MB,连首屏都塞不进;国内六家总包 20 MB,分包容量 16 MB,而可延后资源有 26 MB,分包利用率 162%——即使把能挪的都挪走,也还差 10 MB。
  • Poki 的分包容量为 0(模型里记为 -1,表示「不可分包」),所以任何依赖分包的设计在 Poki 上直接失效。

这份结论的用法不是「选一个装得下的平台」,而是把包体约束提到玩法设计之前。第 4 章会把这里的 -3 MB 拆成一张可执行的裁剪清单(降分辨率、合并图集、延迟加载、首屏降级)。本节只需记住一件事:在小游戏里,「能不能装下」是一个在写第一行玩法代码之前就该算出来的数,而不是上线前才发现的意外。

边界之外:哪些东西不算小游戏

把三重约束反过来用,就能划出小游戏的边界:

  • 原生 App 不是小游戏:它没有主包上限(商店包可以到几百 MB)、可以后台常驻、可以申请任意权限。原生 App 的约束是审核与机型适配,不是包体与加载。
  • 纯网页 H5(无宿主)不是小游戏:它没有平台分发入口,也没有平台 API(支付、广告、社交全要自己接)。本套把「平台宿主 + 分发入口」当作小游戏的定义性特征。
  • 云游戏不是小游戏:算力在云端,本地只是个解码播放器,包体约束基本消失,但换来的是网络延迟与成本约束——那是另一套模型。

所以「小游戏」的边界不是玩法,而是约束集。 一个合成玩法做成小游戏、做成 App、做成云游戏,是三套不同的产品;本节给出的矩阵与预算脚本,只对第一套成立。

小结

  • 小游戏是按运行环境定义的交付形态,由包体 / 加载 / 宿主三重约束刻画;本节只量化第一重。
  • 国内六平台主包上限同档(4 MB),差异在能力项;两个网页平台没有分包。
  • 《合成小岛》首屏 7 MB 已超国内主包 3 MB、总素材 33 MB 只有 CrazyGames 装得下——包体是立项前就要算的硬约束。
  • 下一节把加载约束也量化:首屏包体如何通过加载成功率反向决定进入率。

阅读导航:上一节:目录 · 下一节:1.2 平台生态与分发机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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