把遊戲塞進 13KB
js13kGames
的極限壓縮實戰
把一整個遊戲——程式、圖形、音樂、關卡——塞進比一個 logo 還小的空間。
Leo Kuo
共同創辦人
作品《開拓者》
獲放視大賞銅獎
前端工程師
WhatsApp CRM 新創
React 產品前端
軟體工程師
貨運物流 SaaS
大型 web 產品開發
獨立遊戲開發
單人獨立開發《終局棒球》
React + Phaser + Capacitor
| 東西 | 大小 | 佔 13,312 |
|---|---|---|
|
皮卡丘打排球(1997) 整個遊戲的 zip |
149,513 | 1123% |
|
React + ReactDOM production, min+gzip |
47,199 | 355% |
|
js13k 官網 logo 400×400 JPEG |
20,598 | 155% |
| 一整個 js13k 參賽遊戲 | 13,312 | 100% |
儘管只有 146 KB,但還是 11 個 js13k
這個 logo,就比一整個參賽遊戲還大。
20,598 bytes vs 13,312 bytes。參賽者要在比它更小的空間裡,塞進程式、圖形、音樂、關卡、文字。
「光一張 sprite 圖
就超過 13KB 了吧?」
—— 同事跟我介紹這個比賽的時候,我的第一個反應
工程師為了解決問題而存在的,限制會逼出解決問題的想像力。
就像 game jam 如果完全不限主題,很多人反而卡住。一旦給了題目,創意點子就冒出來了。13KB 是一個很強的題目,它讓「什麼是必要的」變得無法迴避。
規則只有幾條,但很硬
13,312 bytes
13 × 1024,zip 之後的大小。多一個 byte 就是不合格
8/13 → 9/13
每年固定一個月。2012 年由 Andrzej Mazur 創辦至今
函式庫可以用
只是它也要算進那 13KB;不能從 CDN 或外部服務載入,遊戲要能離線跑
每年一個主題
2023 是 13th Century、2024 是 Triskaidekaphobia、2025 是黑貓
參賽作品數:2012 年 62 件,2025 年 197 件
- 開發期間社群也鼓勵在 X 上用 #js13kgames 發 devlog——邊做邊被看見,是很好的推進力
- 參賽者互相玩、互相評分:Theme / Innovation / Gameplay / Graphics / Audio / Controls 六個面向,而且要寫文字回饋
- Post Mortem Fest:投票期間大家寫開發回顧文,分享技術跟卡關
Evil Glitch
2016 年冠軍,後來上了 Steam。無框架無引擎,整包沒有任何圖檔或音檔——畫面用程式碼一筆一筆畫、聲音用程式即時合成。
2023 · Mongol March
主題「13 世紀」→ 蒙古帝國。把方塊排進格子,掃描線一過就召喚部隊出征
實際看看 JS13K 遊戲 demo
- 2048 那樣的操作:上下左右滑,卡片就往那個方向推
- 撞到敵人是攻擊,撞到裝備是撿起來,同款相撞會合成升級
- 三個職業:騎士靠武器、法師靠藥水、防衛者靠反擊——同一套規則,三種玩法
- 每走 13 步出現菁英敵人、物品重量達 13 後每一步扣血(呼應當年的主題)
Templar's Final Stand / js13kGames 2024 /
13,266 bytes
角色、敵人、裝備合成、音樂、音效,全部在裡面
一開始,對於 13KB 能做出什麼根本沒概念
第一次參賽時,完全沒概念一個功能會吃掉多少 bytes。所以我做的第一件事,是去玩前一年的作品。
- 2023 年我拿 Dan Price 的 Norman the Necromancer 當標竿
- 先看清楚「13KB 大概能裝多少東西」——有幾種敵人、幾個關卡、美術做到什麼程度
- 再回頭決定自己的題目要做多大
接著透過 paper prototype 測試。當場就發現第一個點子行不通,在寫任何一行程式之前就砍掉了。
js13kGames 2022 / 用 PNG spritesheet 的像素美術路線
開發遊戲內容前,基礎有兩件事要處理
- 遊戲框架 — Kontra.js
- 音訊函式庫 — ZzFX + ZzFXM
打包本身交給 Vite / Rollup;這三層是打包完之後才動手的。
作者 Steven Lambert 在 2015 年參加完 js13k 之後發現,當時所有的遊戲函式庫都塞不進 13KB,於是把自己專案裡重複用的程式碼整理成一個微型函式庫。
這個函式庫本身,就是被 13KB 這個限制逼出來的產物。
Kontra.js 它幫我做了什麼
GameLoop、Sprite、Animation、輸入(鍵盤/點擊/手勢)、Quadtree 碰撞、Scene、物件池、TileEngine、Vector、Text。沒有框架的話,以上都需要自己刻一份。
可以砍到類別「內部」的功能
// vite.config.ts —— 預設全關,用到才開 kontra({ gameObject: { anchor: true, group: true, opacity: true, scale: true, radius: true }, text: { align: true, newline: true }, }) // 其他功能整包不編進去
最有效的壓縮,是那些
程式碼從一開始就沒被打包進來。
ZzFX:一個音效,就是一串數字
- 數位音訊原理——喇叭發聲靠振膜推動空氣,數位音訊藉由記錄每秒 44.1k 次「振膜推到哪」。所以一秒鐘的聲音 = 44,100 個數字
- ZzFX 做了什麼——平常播 mp3,瀏覽器是把檔案解碼成這串數字;ZzFX 直接用一組參數算出來
- 算完怎麼播——zzfxG(參數…) 產生數字陣列 → 記錄在 AudioBuffer(記憶體裡的一段聲音)→ 接上輸出 → start()
你存的不是聲音,是產生聲音的配方。
attackSFX = [3, , 179, , .03, .06, , 2.8, …]
一個音效=一行參數·一首歌約 1KB
ZzFXM 在 ZzFX 上加一層樂譜:樂器欄位放的就是一組 ZzFX 參數,所以加音樂不用第二套引擎。
ZzFX 音效工具:按隨機、聽、複製參數
ZzFXM tracker:在瀏覽器裡排一首歌
Terser:壓縮程式碼
- 變數與函式名換成 a、b、c
- 拿掉所有空白、換行、註解
- 能簡化的語法就簡化(if/else 併成三元、合併宣告、砍掉用不到的分支)
程式碼還是同一支程式,只是再也不是為了讓人讀懂而存在的。
| 我的專案實測 | 打包後的 JS | 最終 zip |
|---|---|---|
| 關掉 Terser | 143 KB | 15,783 |
| 開 Terser | 51 KB | 13,253 |
target 不要降到 ES5——降級轉譯會生出一堆 helper,在這裡是純浪費;Terser 的 ecma 預設是 5,設高才會產出箭頭函式等更短寫法。
RoadRoller:把整包東西壓成兩行
有趣的是它不是為了比賽而生:作者 Kang Seonghoon 2021 年請假本來想做遊戲,結果做出了這個工具。它替參賽作品多擠出 1–2KB,他還開玩笑「抱歉把 js13kGames 變成了 js15kGames」。
<!-- dist/index.html 總共只有四行 --> <script> M='Kfh~MyXFmkUstuJshrq@zLPaBXlp?lLuBwtufeWuty…' // 16,890 字元:壓縮後的資料 c=1<<15;h=[0,…];a=new Uint16Array(51e6)… // 641 字元:解碼器 </script>
- 它吐出兩行:第一行是資料(存進變數 M),第二行是專門為這份資料生成的解碼器——只有 641 個字元
- 打開網頁時,解碼器把 M 還原成原始 JS 字串,再交給 Function 執行
| 我的專案實測 | index.html | 最終 zip |
|---|---|---|
| 關掉 RoadRoller | 51 KB | 15,692 |
| 開 RoadRoller | 17.6 KB | 13,273 |
所以那個檔案裡沒有一行可讀的遊戲程式碼。遊戲是在瀏覽器載入的那一刻才被算出來的。
好幾個模型各自看不同的前文特徵(前 1 個字元、前 2 個、前 4 個…),各自猜「下一個字元是什麼」的機率;再用一個小型神經網路把這些猜測加權混合,權重邊壓邊學。
拿到機率之後負責寫下實際結果。機率猜得越準,需要寫下的位元就越少——越不意外的事,越不用花力氣描述。
一個負責猜下一個位元,
一個負責把答案寫下來,猜越準,寫越省。
它的模型參數是針對你這一份程式碼搜出來的,所以那個解壓器是客製的。而 README 明講它不擅長「遠距離的重複」——那正是 zip 的強項,所以這兩個是搭配關係。
ECT:輸出 ZIP 格式
zip 的壓縮(DEFLATE)做兩件事:
-
找重複(LZ77)——出現過的內容不再寫一次,改成「往回第 3 個字、抄 9 個」這樣的指標
abcabcabcabc → abc +(往回 3,抄 9) -
常出現的用短碼(Huffman)——對於常出現的給予較短編碼。像摩斯電碼,最常用的 e 給最短的 .
訊號
aaaabbcd → a=0 b=10 c=110 d=111 64 → 14 bits
| 我的專案實測 | 輸入 | 最終 zip |
|---|---|---|
| 不用 ECT(系統內建 zip -9) | 17.5 KB | 13,567 |
| 用 ECT(-strip -zip -10009) | 17.5 KB | 13,252 |
兩條路:程式生成,或一張很小的圖
零個二進位檔案——圖形即時畫。
主角是 11 個多邊形 疊出來的,每層就是一串數字。分層才能讓揮劍、換裝備只動其中一層。
直接放 PNG spritesheet。像素美術特別適合:PNG 內部就是 DEFLATE,體積取決於尺寸、顏色數、有沒有用調色盤。
Norman the Necromancer's sprites.png 一張圖裝下所有角色
看你想呈現的美術風格是哪一種
形狀簡單、幾何、數量不多時極便宜,而且向量無失真,縮放變色都免費。
細節多、不規則、手繪像素時划算。
先把 SVG 本身榨乾
- 畫的時候就先省——刻意減少錨點、把同色區塊合併成一塊。能少一個點就少一個點
- 拿掉 metadata——編輯器存出來的 SVG 夾帶一堆用不到的東西:編輯器版本、圖層名稱、註解、多餘的屬性
- 降低每個點的數值精度——座標常常是 11.5407,這種小數對視覺來說,分辨不出來。SVGOMG 可以一路壓到最低精度
Illustrator 直接匯出盾牌 svg,灰色的都是瀏覽器不需要的:
<?xml version="1.0" encoding="utf-8"?> <!-- Generator: Adobe Illustrator 24.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <svg version="1.2" baseProfile="tiny" id="圖層_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 6.9 6.9" xml:space="preserve"> <g> <circle fill="#AE5D40" cx="3.5" cy="3.5" r="3.5"/> <rect x="3.1" y="2.4" fill="#79444A" width=".7" height="2"/> <polygon fill="#D1B187" points="3.1,3 3.8,3 3.7,3.7 3.2,4 2.6,3.9"/> </g> </svg>
1.51 KB → 653 bytes,壓到原本的 42%。右邊那排開關就是在拿掉 doctype、XML 指令、註解、metadata、編輯器資料。
從「生成 img」到「直接畫」
起點是同一件事:獨立的圖檔不在 RoadRoller 的輸入裡,壓縮壓不到它。所以要想辦法把圖變成「程式碼的一部分」。
2023:路徑寫進程式碼,執行時生成 img
const SVG_DATA = { shield: '<circle cx="13.8" cy="13.8" r="13.8"…', }; // 執行時才把字串變成 <img> Object.entries(SVG_DATA).forEach(…); // 再用 drawImage 畫到 canvas
2024:把向量拆成座標,程式直接畫
// SVG polygon 的座標 → 取整 → 直接畫 drawPolygon(ctx, "1 12 7 12 11 0 0 0 1 12", RED); // 內部就是 moveTo / lineTo / fill // 連 img 都不用生成了
整款遊戲的造型,最後就是 50 幾組這樣的數字。
好控制
每個形狀可以獨立動畫、換色
連 Kontra 都變小
不用 drawImage,就不必開 sprite.image——2023 開著,2024 整個沒寫,那批程式碼直接被 tree-shaking 砍掉。
全部做完,然後超標
2024 年做完所有 polish,興奮地量體積,結果超出上限。砍功能捨不得,那就只剩一個問題:
還有哪裡可以擠出空間?
property 名稱、enum 的 key
Terser 預設完全不碰這兩種名字——它無法確定它們有沒有被外部用到。所以只能自己動手。
const EVENT = { SWIPE: "s", SWIPE_FINISH: "s_f", ITEMS_UPDATED: "i_u" } GameManager.gI() // getInstance enum CardType { T, E, W, S, P } // Templar / Enemy / Weapon / Shield / Potion
但省多少?我把 32 處 gI 全部改回 getInstance 重 build:
| minified JS | 最終 zip | |
|---|---|---|
| getInstance | 51,155 | 13,277 |
| gI | 50,867 | 13,254 |
| 差 | -288 | 約 -21 |
minified 檔上看起來省了 288 bytes,到 zip 只剩 21。94% 是壓縮器本來就會吃掉的。
getInstance 重複 32 次,第二次之後每一次都被壓成一個「往回抄 11 個」的指標。
手動縮短只出現幾次的長名字才划算;出現幾十次的, 那是壓縮器的強項,你搶不到多少。
只剩 46 bytes。
精緻畫面、華麗特效、大量素材——在 13KB 裡一樣都放不下。
剩下的問題變得很純粹:
我到底想給玩家什麼核心體驗?
- 平常做遊戲時可能採用加法:不夠好玩,就再加一個系統、再加一層特效
- 13KB 只准你做減法——每個東西都要先回答「值不值得佔這些空間」,最關鍵要素才會被留下來。
限制會逼你誠實,也會讓你想起做遊戲最原本的樂趣在哪裡。
Kontra.js
LittleJS
Kontra 模組粒度細、可以只帶用到的功能;LittleJS 走 WebGL、效能好,有專為 js13k 準備的精簡版
Canvas API
js13k-2d
多數人直接用內建 Canvas(零成本);js13k-2d 是不到 2KB 的 WebGL sprite renderer
ZzFX / ZzFXM
SoundBox・Sonant-X
前兩個是我用的;SoundBox 音色更厚、Sonant-X 是 Q1K3 那款 FPS 用的
Terser → RoadRoller → ECT
順序就是這樣。ECT 也可以換成 advzip
js13k-ecs・tinyfont.js
Mini 2D physics
1KB 的 ECS、不到 700 bytes 的像素字型、1.5KB 的物理引擎
js13k-typescript-starter
roblouie 做的模板,Vite + TS + 整套壓縮流程都接好了
官方的 js13kGames/resources repo 把這些都收在一起。真正的知識則散在大家的 post-mortem 裡——比賽結束後翻一個晚上,收穫比看教學文大得多。
下一屆:8/13 開賽
Thank You
js13kgames.com
js13kGames 官網
Templar's Final Stand
原始碼
Threads
@kuo0724
附錄
前面提過 Terser 不會動兩者:property 名稱 和 enum 讓程式碼變多的陷阱 。附錄三頁是那兩件事各自的實測細節。
- property 名稱——mangle.properties 能省下多少,以及它為什麼會把遊戲弄壞
- enum 讓程式碼變多的陷阱——關於列舉,四種寫法的實際大小差距
Terser 可以壓 property 的設定:mangle.properties
這一步在做的事:把物件的 property 名稱也改短,例如 card.attackDirection → card.a。
| 版本 | zip | 省下 |
|---|---|---|
| Templar's Final Stand 原本設定 | 13,266 | — |
| mangle: { properties: true } | 12,654 | −612 |
| 加上 reserved 白名單 | 12,703 | −563 |
白名單裡放的,就是那些會被當成字串使用的 property 名稱
白名單的理由:程式碼把 property 名稱當成「資料」在用
① 物件的 property 被改名了
// 原本 buff = { attackDirection: "front" } // mangle 後 buff = { a: "front" }
② 但比對用的字串沒有跟著改
if (key === "attackDirection") … // key 現在是 "a" // 這行永遠不成立
- Terser 只改屬性的名字,不會去動字串字面量——它無從得知那個字串是拿來比對同一個屬性的
- 連帶的:程式裡把 key 直接印出來的地方,玩家會看到壓縮後的亂碼名字
以上原因所以需要白名單 bypass 或者換寫法。
enum 讓程式碼變多的陷阱
TypeScript 的 enum 會編譯成一個執行期物件,而且因為要支援反向查表(CardType[0] === "T"),每個 key 被寫進檔案兩次。有另外三種方式解決:
enum CardType { T, E, W, S, P } // ↓ 產出 var C;!function(e){ e[e.T=0]="T",e[e.E=1]="E",… }(C||(C={}))
每個 key 寫兩次,terser 完全動不了
const enum CardType { T, E, W, S, P } // ↓ 產出 // enum 整個不存在 console.log(2)
要關掉 isolatedModules,dev 與 prod 的表示法從此不同
const CardType = { T:0, E:1, W:2 } as const // ↓ 產出(passes≥2) console.log(2)
型別安全變弱;在 minify 時優化,而不是 tsc 轉譯時
type CardType = "T"|"E"|"W"|"S"|"P" // ↓ 純型別,沒有產出 console.log("W")
型別安全一樣弱、沒有 namespace,值散在每個使用點
| 整個專案改寫後的 zip | zip | 省下 |
|---|---|---|
| ① enum(我當年的寫法) | 13,217 | — |
| ② const enum | 12,880 | 337 |
| ③ as const 物件 | 12,902 | 315 |
| ④ string literal union | 12,975 | 242 |
④ 比 ③ 大,不是因為 union 這個做法,而是值變成字串。同樣是 union、值改用數字可以壓到 12,848——全場最小,但 call site 會變成 spawnEnemy(2),而且六個型別的值全部重疊、可以互相代換。 省下的 54 bytes 不值得。