COSCUP 2026 // 13,312 BYTES

把遊戲塞進 13KB
js13kGames 的極限壓縮實戰

把一整個遊戲——程式、圖形、音樂、關卡——塞進比一個 logo 還小的空間。

Leo Kuo // 前端工程師 → 獨立遊戲開發
00 // SPEAKER

Leo Kuo

CITYCHASER

共同創辦人

作品《開拓者》
獲放視大賞銅獎

COOBY

前端工程師

WhatsApp CRM 新創
React 產品前端

GOFREIGHT

軟體工程師

貨運物流 SaaS
大型 web 產品開發

NOW

獨立遊戲開發

單人獨立開發《終局棒球》
React + Phaser + Capacitor

01 // 13,312 BYTES 有多小
東西 大小 佔 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

js13kGames logo

這個 logo,就比一整個參賽遊戲還大。

20,598 bytes vs 13,312 bytes。參賽者要在比它更小的空間裡,塞進程式、圖形、音樂、關卡、文字。

01 // 為什麼要玩這個

「光一張 sprite 圖
就超過 13KB 了吧?」

—— 同事跟我介紹這個比賽的時候,我的第一個反應

本場真正分享的事

工程師為了解決問題而存在的,限制會逼出解決問題的想像力。

就像 game jam 如果完全不限主題,很多人反而卡住。一旦給了題目,創意點子就冒出來了。13KB 是一個很強的題目,它讓「什麼是必要的」變得無法迴避。

02 // JS13KGAMES 規則

規則只有幾條,但很硬

SIZE

13,312 bytes

13 × 1024,zip 之後的大小。多一個 byte 就是不合格

TIME

8/13 → 9/13

每年固定一個月。2012 年由 Andrzej Mazur 創辦至今

LIBRARY

函式庫可以用

只是它也要算進那 13KB;不能從 CDN 或外部服務載入,遊戲要能離線跑

THEME

每年一個主題

2023 是 13th Century、2024 是 Triskaidekaphobia、2025 是黑貓

02 // 這比賽有多大、大家怎麼玩
規模
62 → 197

參賽作品數:2012 年 62 件,2025 年 197 件

  • 開發期間社群也鼓勵在 X 上用 #js13kgames 發 devlog——邊做邊被看見,是很好的推進力
  • 參賽者互相玩、互相評分:Theme / Innovation / Gameplay / Graphics / Audio / Controls 六個面向,而且要寫文字回饋
  • Post Mortem Fest:投票期間大家寫開發回顧文,分享技術跟卡關
從這裡長出去的作品

Evil Glitch

2016 年冠軍,後來上了 Steam。無框架無引擎,整包沒有任何圖檔或音檔——畫面用程式碼一筆一筆畫、聲音用程式即時合成。

02 // 我的兩次參賽

2023 · Mongol March

主題「13 世紀」→ 蒙古帝國。把方塊排進格子,掃描線一過就召喚部隊出征

02 // LIVE DEMO

實際看看 JS13K 遊戲 demo

  • 2048 那樣的操作:上下左右滑,卡片就往那個方向推
  • 撞到敵人是攻擊,撞到裝備是撿起來,同款相撞會合成升級
  • 三個職業:騎士靠武器、法師靠藥水、防衛者靠反擊——同一套規則,三種玩法
  • 每走 13 步出現菁英敵人、物品重量達 13 後每一步扣血(呼應當年的主題)

Templar's Final Stand / js13kGames 2024 / 13,266 bytes
角色、敵人、裝備合成、音樂、音效,全部在裡面

03 // 開始之前:先知道 13KB 裝得下什麼

一開始,對於 13KB 能做出什麼根本沒概念

第一次參賽時,完全沒概念一個功能會吃掉多少 bytes。所以我做的第一件事,是去玩前一年的作品。

  • 2023 年我拿 Dan Price 的 Norman the Necromancer 當標竿
  • 先看清楚「13KB 大概能裝多少東西」——有幾種敵人、幾個關卡、美術做到什麼程度
  • 再回頭決定自己的題目要做多大

接著透過 paper prototype 測試。當場就發現第一個點子行不通,在寫任何一行程式之前就砍掉了。

Norman the Necromancer

js13kGames 2022 / 用 PNG spritesheet 的像素美術路線

04 // 接下來要講的東西

開發遊戲內容前,基礎有兩件事要處理

① 選型:一開始就佔掉的空間
  • 遊戲框架 — Kontra.js
  • 音訊函式庫 — ZzFX + ZzFXM
② 壓縮:把剩下的擠出來
Terserminify
RoadRoller壓成會自己解開的 JS
ECTzip

打包本身交給 Vite / Rollup;這三層是打包完之後才動手的。

04 // 技術選型:遊戲函式庫
Kontra.js 的起源

作者 Steven Lambert 在 2015 年參加完 js13k 之後發現,當時所有的遊戲函式庫都塞不進 13KB,於是把自己專案裡重複用的程式碼整理成一個微型函式庫。

這個函式庫本身,就是被 13KB 這個限制逼出來的產物。

Kontra.js 它幫我做了什麼

GameLoop、Sprite、Animation、輸入(鍵盤/點擊/手勢)、Quadtree 碰撞、Scene、物件池、TileEngine、Vector、Text。沒有框架的話,以上都需要自己刻一份。

04 // 選它的關鍵理由

可以砍到類別「內部」的功能

// vite.config.ts —— 預設全關,用到才開
kontra({
  gameObject: { anchor: true, group: true, opacity: true,
                scale: true, radius: true },
  text: { align: true, newline: true },
})  // 其他功能整包不編進去

最有效的壓縮,是那些
程式碼從一開始就沒被打包進來。

04 // 技術選型 · 音訊

ZzFX:一個音效,就是一串數字

  • 數位音訊原理——喇叭發聲靠振膜推動空氣,數位音訊藉由記錄每秒 44.1k 次「振膜推到哪」。所以一秒鐘的聲音 = 44,100 個數字
  • ZzFX 做了什麼——平常播 mp3,瀏覽器是把檔案解碼成這串數字;ZzFX 直接用一組參數算出來
  • 算完怎麼播——zzfxG(參數…) 產生數字陣列 → 記錄在 AudioBuffer(記憶體裡的一段聲音)→ 接上輸出 → start()
所以真正被存進 13KB 的是什麼

你存的不是聲音,是產生聲音的配方。

attackSFX = [3, , 179, , .03, .06, , 2.8, …]

一個音效=一行參數·一首歌約 1KB

ZzFXM 在 ZzFX 上加一層樂譜:樂器欄位放的就是一組 ZzFX 參數,所以加音樂不用第二套引擎。

ZzFX 音效設計工具

ZzFX 音效工具:按隨機、聽、複製參數

ZzFXM 線上 tracker

ZzFXM tracker:在瀏覽器裡排一首歌

05 // 壓縮階梯 · Terser
Terser→RoadRoller→ECT

Terser:壓縮程式碼

  • 變數與函式名換成 a、b、c
  • 拿掉所有空白、換行、註解
  • 能簡化的語法就簡化(if/else 併成三元、合併宣告、砍掉用不到的分支)

程式碼還是同一支程式,只是再也不是為了讓人讀懂而存在的。

我的專案實測 打包後的 JS 最終 zip
關掉 Terser 143 KB 15,783
開 Terser 51 KB 13,253
容易忽略的設定

target 不要降到 ES5——降級轉譯會生出一堆 helper,在這裡是純浪費;Terser 的 ecma 預設是 5,設高才會產出箭頭函式等更短寫法。

05 // 壓縮階梯 · RoadRoller
Terser→RoadRoller→ECT

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

所以那個檔案裡沒有一行可讀的遊戲程式碼。遊戲是在瀏覽器載入的那一刻才被算出來的。

05 // RoadRoller 的原理
Terser→RoadRoller→ECT
預測部門 · logistic context mixing

好幾個模型各自看不同的前文特徵(前 1 個字元、前 2 個、前 4 個…),各自猜「下一個字元是什麼」的機率;再用一個小型神經網路把這些猜測加權混合,權重邊壓邊學。

記錄部門 · bytewise rANS

拿到機率之後負責寫下實際結果。機率猜得越準,需要寫下的位元就越少——越不意外的事,越不用花力氣描述。

一個負責猜下一個位元,
一個負責把答案寫下來,猜越準,寫越省。

它的模型參數是針對你這一份程式碼搜出來的,所以那個解壓器是客製的。而 README 明講它不擅長「遠距離的重複」——那正是 zip 的強項,所以這兩個是搭配關係。

05 // 壓縮階梯 · zip
Terser→RoadRoller→ECT

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
06 // 圖片資產怎麼辦

兩條路:程式生成,或一張很小的圖

我的路線

零個二進位檔案——圖形即時畫。

① 身體
② +鞋
③ +頭
④ +前手

主角是 11 個多邊形 疊出來的,每層就是一串數字。分層才能讓揮劍、換裝備只動其中一層。

另一條很多人走的路

直接放 PNG spritesheet。像素美術特別適合:PNG 內部就是 DEFLATE,體積取決於尺寸、顏色數、有沒有用調色盤。

Norman 的 sprites.png:160×70 的像素美術 spritesheet

Norman the Necromancer's sprites.png 一張圖裝下所有角色

06 // 那到底該選哪一條

看你想呈現的美術風格是哪一種

程式畫:成本 ∝ 描述的複雜度

形狀簡單、幾何、數量不多時極便宜,而且向量無失真,縮放變色都免費。

放 PNG:成本 ∝ 像素數量

細節多、不規則、手繪像素時划算。

06 // 向量圖 ①

先把 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>
SVGOMG 壓縮設定

1.51 KB → 653 bytes,壓到原本的 42%。右邊那排開關就是在拿掉 doctype、XML 指令、註解、metadata、編輯器資料。

06 // 向量圖 ②

從「生成 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 幾組這樣的數字。


01

好控制

每個形狀可以獨立動畫、換色

02

連 Kontra 都變小

不用 drawImage,就不必開 sprite.image——2023 開著,2024 整個沒寫,那批程式碼直接被 tree-shaking 砍掉。

07 // 最後 300 bytes
13,600 / 13,312 BYTES

全部做完,然後超標

2024 年做完所有 polish,興奮地量體積,結果超出上限。砍功能捨不得,那就只剩一個問題:

還有哪裡可以擠出空間?

07 // Terser 不會動的兩個地方

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 個」的指標。

原則

手動縮短只出現幾次的長名字才划算;出現幾十次的, 那是壓縮器的強項,你搶不到多少。

07 // 送出
13,266 / 13,312 BYTES (99.65%)

只剩 46 bytes。

08 // JS13KB 教會我的事

精緻畫面、華麗特效、大量素材——在 13KB 裡一樣都放不下。

剩下的問題變得很純粹:
我到底想給玩家什麼核心體驗?

  • 平常做遊戲時可能採用加法:不夠好玩,就再加一個系統、再加一層特效
  • 13KB 只准你做減法——每個東西都要先回答「值不值得佔這些空間」,最關鍵要素才會被留下來。

限制會逼你誠實,也會讓你想起做遊戲最原本的樂趣在哪裡。

09 // 起手包
遊戲框架

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 開賽

COSCUP 2026 // THANKS

Thank You

js13kgames.com

js13kgames.com
js13kGames 官網

Templar's Final Stand

Templar's Final Stand
原始碼

Threads

Threads
@kuo0724

APPENDIX // 附錄

附錄

前面提過 Terser 不會動兩者:property 名稱 和 enum 讓程式碼變多的陷阱 。附錄三頁是那兩件事各自的實測細節。

  • property 名稱——mangle.properties 能省下多少,以及它為什麼會把遊戲弄壞
  • enum 讓程式碼變多的陷阱——關於列舉,四種寫法的實際大小差距
附錄 // property 名稱 ①

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 名稱當成「資料」在用

① 物件的 property 被改名了

// 原本
buff = { attackDirection: "front" }

// mangle 後
buff = { a: "front" }

② 但比對用的字串沒有跟著改

if (key === "attackDirection") …
// key 現在是 "a"
// 這行永遠不成立
  • Terser 只改屬性的名字,不會去動字串字面量——它無從得知那個字串是拿來比對同一個屬性的
  • 連帶的:程式裡把 key 直接印出來的地方,玩家會看到壓縮後的亂碼名字

以上原因所以需要白名單 bypass 或者換寫法。

附錄 // enum 的 key

enum 讓程式碼變多的陷阱

TypeScript 的 enum 會編譯成一個執行期物件,而且因為要支援反向查表(CardType[0] === "T"),每個 key 被寫進檔案兩次。有另外三種方式解決:

① enum
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
const enum CardType { T, E, W, S, P }
// ↓ 產出
// enum 整個不存在
console.log(2)

要關掉 isolatedModules,dev 與 prod 的表示法從此不同

③ as const 物件
const CardType = {
  T:0, E:1, W:2 } as const
// ↓ 產出(passes≥2)
console.log(2)

型別安全變弱;在 minify 時優化,而不是 tsc 轉譯時

④ string union
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 不值得。