「小游戏」这个词在行业里几乎从不指同一件事。有人说的是一段能在微信里点开就玩的 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 平台生态与分发机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。