COSCUP 2026

不只是 Canvas:
React + Phaser 的前端遊戲架構實戰

該記住的進 store,一次性的走 event bus,60fps 的留在 Phaser。

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

Leo Kuo

CITYCHASER

共同創辦人

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

COOBY

前端工程師

WhatsApp CRM 新創
React 產品前端

GOFREIGHT

軟體工程師

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

NOW

全職遊戲開發

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

帶著前端技能做遊戲。平常也參加 game jams(gamedev.js、js13kGames)。

00 // 這場演講
  • 用網頁技術做遊戲的取捨——什麼時候適合、什麼時候不適合
  • 適合的時候,現代前端框架要怎麼跟遊戲渲染畫面搭配

一開始做《終局棒球》時,因為比較熟悉網頁技術,所以就選了 canvas + DOM 的結構,後來發現,這套網頁遊戲架構其實也有些好處。

Phaser
Phaser — 開源的 HTML5 2D 遊戲框架,MIT 授權。這場演講裡它負責 canvas 裡的那一半:場景、精靈、物理、輸入。
01 // 遊戲定位

選工具之前,先回答三個問題

Q1

什麼遊戲?

2D UI 密集?上萬 entity 的模擬?還是 3D?

Q2

什麼平台?

Web?Mobile?還是 Steam / 主機?

Q3

什麼階段?

prototype?還是長期營運的產品?

比較之前先定位,沒有定位的話,都是信仰之爭。

01 // 遊戲定位

為什麼用網頁技術做遊戲

01

通路就在瀏覽器

要做的本來就是網頁遊戲,貼個連結就能玩

02

最快從想法到能玩

想快速驗證 prototype 的時候

03

既有技能直接複用

網頁工程師不用重學,一份 code 出 Web・PC・Mobile

反過來說:要做 3D、注重效能、或要發布到主機——這些 web 可能不合適,考慮用引擎比較快。

01 // 遊戲定位

為什麼這條路的 prototype 特別快

  • AI 執行力最強的地盤——TypeScript 語料庫最大;專案狀態 100% 是文字,AI 讀得懂、也改得動全部;agent 能自己「寫 → 跑 → 看截圖 → 修」
  • 平行開發是預設——git worktree × N 個 dev server × N 個 agent
  • 部署 = 丟一個連結——build 出來就是網頁,貼給任何人點開就能玩
// 對照組:AI 讀得懂語法,但生不出正確的值
--- !u!114 &1660057541
MonoBehaviour:
  m_Script: {fileID: 11500000,
    guid: fc0d4c8e2b3a4f1, type: 3}
  m_Father: {fileID: 963194228}

業界作法:AI 不直接編 YAML,改走 MCP 驅動編輯器 API,代價是需要開著編輯器:重、改完要 domain reload 才生效

編譯與 domain reload 打斷節奏、給人試玩要下載安裝、每份 worktree 各自重建 Library(Unity)、GDScript 語料稀少(Godot)。

01 // 遊戲定位 · 實際案例

網頁技術做的遊戲,可以走多遠?

  • 2020 年,Poncle 用 HTML5 / canvas 技術開始寫
  • 2022 年,它成了年度爆款——Steam 壓倒性好評
  • 爆紅的初版,就是網頁技術做的
Vampire Survivors 遊戲畫面——滿螢幕的敵人與傷害數字
02 // 兩種繪製世界

Canvas 與 DOM,是兩種相反的繪製哲學

  • requestAnimationFrame 當節拍器,遊戲迴圈的慣例是每幀 重畫整個場景
  • API 不保留「物件」:沒有元素、沒有 layout、沒有事件目標,只有像素
  • HTML5 遊戲引擎(Phaser、PixiJS…)= 在這上面自建 scene graph、批次渲染、input 判定
  • 這正是 60fps 遊戲該有的模型
  • 瀏覽器持有元素樹,layout / paint / composite 增量處理
  • 附送三十年累積:文字排版、自動換行、字型、捲動
  • 表單、responsive web design
  • 這正是產品介面該有的模型
02 // 兩種繪製世界

硬要跨界,兩邊都是在重造輪子

  • 文字換行自己算、輸入框自己刻、捲動清單自己寫
  • 中文 IME 輸入在 canvas 裡幾乎無解
  • 設定頁、商店、帳號流程。UI 一多就是無底洞
  • 上千個會動的元素 = layout / paint 全面爆炸
  • 重畫由瀏覽器排程,掉幀時沒有「這幀少畫一點」這個選項
  • 每幀配置節點與字串引來 GC 停頓、layout 成本隨結構浮動

canvas 管遊戲世界(擅長每幀重畫),DOM 管產品介面(擅長排版互動)。

02 // 問題陳述

當遊戲不只是 canvas 裡的一段互動——
畫面一多、流程一長,兩個世界開始
搶狀態搶 input

邊界切分,就成了架構的關鍵。

03 // 架構 · LIVE DEMO

先看 demo,再拆解

  • 一個 30 秒的小遊戲——React 做介面Phaser 跑遊戲
  • 藍框是 React、橘框是 Phaser、右側是兩個世界的即時對話

本章起,遊戲引擎以 Phaser 為例;前端框架同理可換成 Angular / Vue / Svelte。

github.com/leokuo0724/phaser-stacks

03 // 架構 · LIVE DEMO
03 // 架構總覽 · 疊層

同一個螢幕,兩個世界怎麼疊?

/* 遊戲畫布:墊在最下面 */
.game-canvas { z-index: 0; }

/* UI 層:蓋在上面,但預設「穿透」 */
.ui-layer {
  z-index: 1;
  pointer-events: none;
}
/* 只有互動元件自己接住事件 */
.ui-layer button { pointer-events: auto; }
  • 不設穿透會怎樣?UI 層是蓋住整個畫面的透明 div——所有點擊都被它吃掉,玩家永遠點不到遊戲
  • 所以整層設 pointer-events: none:事件穿過 UI 層、落到 canvas
  • 需要互動的按鈕各自 opt-in auto——點按鈕是 React 的、點畫面是遊戲的,零事件轉發程式碼
03 // 架構總覽 · 資料流

跨界資料流切分

選單・計分 UI・modal・流程切換
產品介面的世界

scene・sprite・physics・input
60fps 的世界

▲ 兩邊都只透過下面兩層溝通 ▼

Redux —— 該記住的狀態
status / score / combo…

event bus —— 一次性的瞬間
ui:celebrate / game:hit…

守則:react/ 的程式碼不准 import game/game/ 也不准 import react/——要溝通,只能經過 store 與 event bus。

04 // 邊界切分

該記住的進 store,
一次性的走 event bus。

04 // 邊界切分 · 該記住的

該記住的:之後還有人要讀的狀態

// store/game/slice.ts —— 單一事實來源
{
  status: "playing",   // 流程
  score: 240,           // UI 要顯示
  combo: 6,             // UI 要顯示
  timeLeft: 12,         // 兩邊都關心
  highScore: 512,       // 要持久化
}
  • 特徵:低頻變動、多方讀取、值得持久化
  • React 用 useAppSelector 讀、Phaser 透過橋接層讀寫
  • 兩邊都不持有副本、只讀寫同一份,不用互相同步
04 // 邊界切分 · 一次性

一次性:發生完就不需要存的「瞬間」

// shared/event-keys.ts —— 型別化契約
type GameEvents = {
  // React → Phaser(一次性指令)
  "ui:celebrate": void;
  // Phaser → React(一次性事實)
  "game:hit": { x; y; points; combo };
  // 就這些——status 家族全走 store
};
  • 特徵:發生完就沒了,不值得存——存了反而要煩惱清除
  • mitt<GameEvents>:兩層共用同一份型別,編譯期保障
04 // 邊界切分 · 橋接

StoreManager:遊戲程式碼唯一碰 store 的檔案

// game/managers/store-manager.ts
class StoreManager {
  select<T>(fn: (s: RootState) => T): T {
    return fn(store.getState());
  }
  dispatch(action: Action) {
    store.dispatch(action);
  }
  subscribe(fn: () => void) {
    return store.subscribe(fn);
  }
}
  • 把依賴刻意收窄到一個檔案——整個 game/ 與 store 的往來都經過這裡
  • 想換 Zustand / Jotai?只改這一個檔
  • code review 時 grep 一下,就知道有沒有人繞過邊界
04 // 邊界切分 · 接線 ①

接線:React → Phaser 的兩條路

// React → Phaser:暫停選單改難度,遊戲立刻變速
// React — PauseModal.tsx
dispatch(setDifficulty("hard"));

// Phaser — GameScene.create()
this.unsubscribe = storeManager.subscribe(() => {
  const d = storeManager.select(selectDifficulty);
  if (d === this.prev) return; // 自己 diff
  this.applyDifficulty(d);     // 即時生效
});

status 家族同路——prevStatus 分辨 start / resume;cleanup 記得退訂

// React → Phaser:按鈕驅動煙火效果
// React — Hud.tsx
<button onClick={() =>
  emitter.emit("ui:celebrate")
}>🎉</button>

// Phaser — GameScene
emitter.on("ui:celebrate", () =>
  this.handleCelebrate() // 噴 3–5 波粒子
);

「慶祝一下」不需要被記住——硬存 store 就得煩惱誰去清 isCelebrating

Demo:暫停改難度 → 繼續立刻變速(左);按 🎉 噴粒子、event log 飄事件(右)。

04 // 邊界切分 · 接線 ②

接線:Phaser → React 的兩條路

// Phaser → React:打擊累加分數,HUD 自動重繪
// Phaser — GameScene(經 StoreManager)
storeManager.dispatch(
  registerHit({ base, combo })
);

// React — Hud.tsx
const score = useAppSelector(selectScore);
return <div>{score}</div>; // 自動重繪

分數之後要顯示、要存檔——該記住的;React 這端框架自動 re-render

// Phaser → React:打擊驅動 combo UI 顯示
// Phaser — GameScene
emitter.emit("game:hit", { x, y, points, combo });

// React — ComboToast.tsx
useEffect(() => {
  const onHit = ({ combo }) =>
    combo % 5 === 0 && show(combo);
  emitter.on("game:hit", onHit);
  return () => emitter.off("game:hit", onHit);
}, []);

里程碑 toast 亮 1 秒就消失——一次性,不進 store;unmount 記得 off

Demo:連擊看分數跳動(左);combo 到 5 的倍數彈「COMBO ×5!」(右)。

04 // 邊界切分 · 統整

四種接法

該記住的(之後要讀)→ store 一次性(不需存)→ event bus
Phaser → React dispatch:registerHit 改 score,UI 自動重繪 emit:game:hit → Combo 里程碑 toast
React → Phaser subscribe:改 difficulty,場景即時調速(接線 B) emit:ui:celebrate 噴粒子(接線 A)

「這個值,canvas 外有人要嗎?」——沒有,就留在 Phaser(sprite 位置、velocity、tween、timer 每幀都在變,一秒 60 次 dispatch 沒有意義);有,才看要不要記住:要 → store、一次性 → bus。

04 // 邊界切分 · 持久化

狀態管理做對,存檔就好處理

  • 該記住的都集中在 store → 掛一個 auto-save middleware,變動就自動寫出去;寫到 localStorage、裝置端 Preferences 還是雲端,只是換這一層的實作
  • 啟動時讀回 initial state → reload 之後,進度、高分、設定全部都在
  • 遺失的只有 canvas 裡的瞬時狀態——那些本來就該重算重繪

同一套機制在三個地方:開發中的整頁 reload玩家裝置的意外重啟載入特定 dataset 重現場景來測試

05 // 通路

一份 code,三個世界

WEB

瀏覽器

原生平台,URL 即玩、秒載入、可 embed

MOBILE

Capacitor / Cordova

iOS / Android
VS 手機版當年就是這條路(Capacitor 官方文件點名)

DESKTOP

Electron / Tauri

桌面應用
VS 的 Steam 版當年就是網頁版 + Electron

吸血鬼倖存者在網頁版時代,就用這一套吃下了三個通路

06 // 總結

重點整理

想快速驗證想法時,這條路的優勢最大:部署即連結、AI 與平行開發。
要長期營運時看訊號:通路在瀏覽器、效能可控 → 留下;3D/重模擬/上主機/美術要自己動手 → 考慮換引擎。

UI 密集(選單、設定、商店、表單、中文輸入——就拉進來),考慮結合 DOM
只有幾顆按鈕的話,純 canvas 就夠,不用扛一整個框架。

該記住的store | 一次性的event bus | 60fps 的 → 留在 Phaser

06 // 總結 · 實際案例

2023 年,Vampire Survivors 移植到 Unity

官方理由:效能、硬體相容性、上主機
撞到訊號就搬家,不過網頁版早已幫它賺完驗證與爆紅。

COSCUP 2026 // THANKS

Thank You

phaser-stacks QR code

Demo 模板
phaser-stacks

Threads QR code

Threads
@kuo0724

終局棒球:打擊畫面 終局棒球:守備畫面

《終局棒球》——React + Phaser + Capacitor,TestFlight 測試中

APPENDIX // CAPACITOR

Capacitor:把你的網頁裝進手機 App

App 裡放著一個系統瀏覽器元件(WebView:iOS 的 WKWebView、Android System WebView,你的 JS / HTML / CSS 就跑在裡面;打包時整包 web 資產塞進 app 本體、從本地載入——不是連線去開一個網址

  • 整合只動 ~2 個檔案——架構層完全不知道自己在 App 裡
  • 原生能力用 plugin 補:@capacitor/preferences(存檔)、@capacitor/haptics(震動)
  • WebView 裡的 JS 有完整 JIT、canvas/WebGL 有 GPU 加速——天花板在單一 JS 主執行緒
APPENDIX // APP 更新

APP 更新的三種模式

模式一 · 商店發版

換的是 app 本體:原生殼、plugin 的原生碼、Capacitor runtime。binary 變了 → 必走商店審查

模式二 · OTA (Over-the-Air)

app 本體不動,只把包在 app 裡的 web 資產換新:連線下載新 bundle 存到本地、下次啟動切換。免審查——明文允許 ①

模式三 · 包網址

WebView 直接連遠端網站,資產不在 app 裡。離線即死、全看網速,還有拒審風險 ②。不要用

原生層走商店,但遊戲本體活在 JS 層
邊界切分切得好,連發版策略都受益

① Apple 開發者合約 §3.3.1(B)(舊編號 3.3.2):App 不得下載可執行碼;唯一例外=由內建 WebKit / JavascriptCore 執行的直譯碼,且不得改變 App 主要用途
② App Review Guidelines §4.2 Minimum Functionality:App 須超越「重新包裝的網站」——純包網址的殼 app 常據此被拒

← → SPACE 換頁