一款雙人線上對戰遊戲,怎麼做到全免費?技術架構全解析

最近我做了一個自己玩得很開心的 side project——一款打開瀏覽器就能雙人線上對戰的戰鬥陀螺遊戲。這個系列會寫兩篇,這篇先從技術面開講,聊一件很多人會好奇的事:一款有帳號、有即時連線、有資料庫的雙人線上對戰遊戲,怎麼可能不用付伺服器月租?
答案不是「省著用」,而是從架構設計開始就讓它幾乎不耗資源。這篇就把整套技術拆給你看:用了什麼框架、WebSocket 怎麼接、用了哪些雲端服務,以及最關鍵的——那個讓它能全免費的核心設計決策。

技術棧總覽
先把用到的東西攤開:
| 層 | 技術 |
|---|---|
| 前端框架 | Vue 3 + TypeScript + Vue Router |
| 建置工具 | Vite |
| 即時連線 | WebSocket |
| 運算 / 後端 | Cloudflare Workers |
| 房間狀態 | Durable Objects(Battle Room / Lobby) |
| 資料庫 | Cloudflare D1(SQLite) |
| 檔案儲存 | Cloudflare R2(音效素材) |
| 登入 | Google OAuth 2.0 |
整個專案是單一個 repo、單一個 Worker:同一個 Worker 同時供應靜態前端網頁,以及所有 /api/* 的後端請求。前端 build 出來的靜態檔走 Cloudflare 的 Static Assets(等於免費的全球 CDN),動態請求才落到 Worker 程式。這套「靜態 + 後端同一個 Worker」的部署方式,我之前寫過 一篇專文 詳談。
核心決策:確定性批次模擬
這是整個專案最重要的一個架構決策,也是「能免費」的根本原因。一句話講完:
伺服器不是即時模擬整場對戰,而是在收齊雙方發射參數後,「一次」把整場算完,廣播一個約 2KB 的「重跑包」,由前端自己重新算出一模一樣的畫面來播放。
要理解它為什麼省,先看傳統即時對戰怎麼做:伺服器要跑一個高頻 game loop,每秒 30~60 次不斷計算每顆陀螺的位置、碰撞、狀態,再把這些狀態持續推送給雙方。只要對戰還沒結束,伺服器的 CPU 就一刻不得閒——這是要錢的,而且場次越多越貴。
這款遊戲完全反過來。因為它的玩法是「發射參數定生死、對戰中不能操作」(玩法篇有詳述),所以根本不需要即時運算。流程變成:
- 雙方各自送出發射參數(落點、方向、力道)給伺服器。
- 伺服器收齊後,產生一個亂數種子,用物理引擎一口氣把整場對戰算完,得出勝負。
- 伺服器只廣播一個 重跑包:初始資料 + 亂數種子 + 場地設定,加起來約 2KB。
- 前端拿到重跑包,用同一份物理引擎、同一個種子重算一次,得到逐位元相同的軌跡,然後播放成動畫。

這個設計帶來一連串好處:
- 伺服器運算量極小:一場對戰只算一次(而且是純數學、毫秒級),不是跑幾十秒的 game loop。
- 頻寬極省:完整的對戰軌跡如果直接傳,大概要 1.5MB;改傳 2KB 的重跑包,省了將近 1000 倍。
- 伺服器權威、客戶端無法作弊:玩家只能送發射參數,陀螺的真實數值是伺服器自己組裝的,改前端封包也買不到更強的陀螺。亂數種子是「雙方都提交後」才產生,也擋掉了離線暴力搜尋最佳發射參數的可能。
- 雙方畫面 100% 一致:兩端播的是同一份計算結果。
那浮點數跨平台不一致怎麼辦?
寫過這類「兩端各自重算」的人會立刻問:不同裝置、不同瀏覽器的浮點數運算有微小誤差,重算出來不就對不上了嗎?
這裡有個巧妙的點:真正決定勝負的那次計算,只發生在伺服器一端。 前端的重算純粹是「為了畫出動畫」,就算兩邊浮點數有極微小差異,畫面差個半像素肉眼根本看不出來,而且勝負與計分一律以伺服器算出的結果為準,前端不重新判定輸贏。所以跨平台浮點數一致性這個經典難題,在這個架構下直接消失了。
為了讓這套行得通,物理引擎被刻意寫成純函式、零瀏覽器相依——同一份程式碼可以原封不動地跑在瀏覽器和伺服器兩邊,再加上固定時間步與可控的亂數產生器(seeded PRNG),確保「同樣的輸入 + 同樣的種子 → 完全一樣的結果」。
WebSocket + Durable Object
即時連線用 WebSocket,但連到哪裡?這就要講 Cloudflare 的 Durable Object(DO)。
一般的 Worker 是無狀態的、每次請求可能跑在不同節點,沒辦法拿來存「一個房間裡兩個人的對戰狀態」。Durable Object 補上這塊:它是有狀態、單一實例的物件,很適合拿來當「房間」。這專案有兩種 DO:
- Battle Room DO:一場對戰一個。它是這場比賽的權威狀態機,握著雙方的 WebSocket 連線,跑
等待 → 瞄準 → 運算 → 回放 → 下一回合 → 結束的流程,負責收發射參數、算勝負、計分、廣播重跑包。 - Lobby DO:全站只有一個。管大廳的線上人數、快速配對佇列、公開房列表。
玩家瀏覽器開一條 WebSocket 連到 Worker,Worker 驗證好登入身份後,把連線轉接到對應的 Durable Object。從此這條連線上的所有訊息(發射、換陀螺、進下一回合⋯⋯)都由那個 DO 處理。

關鍵省錢機制:WebSocket Hibernation
這是讓 WebSocket 也能免費的核心。傳統上,一條 WebSocket 連著,伺服器那端的物件就得一直待在記憶體裡「醒著」等訊息——也就是一直計費。
Cloudflare 的 WebSocket Hibernation(休眠) 改變了這件事:DO 可以在「連線還開著、但沒有訊息進來」的時候進入休眠、不佔用運算資源,等到有訊息才被喚醒處理。對這款遊戲來說太契合了——玩家大部分時間都在思考要怎麼瞄準,連線開著但沒有任何訊息流動,這段時間 DO 全程休眠、不計費。
再配一個小技巧:WebSocket 為了保持連線,客戶端每隔一段時間要送「心跳(ping)」。如果每個 ping 都喚醒 DO,那休眠就白搭了。所以這裡用 DO 的 auto-response 功能,設定成「收到 ping 自動回 pong」——這個回覆由底層直接處理,根本不會吵醒 DO。連線穩穩保活,DO 卻一直在睡。
用到哪些雲端服務
整套都在 Cloudflare 生態系裡,沒有第二家供應商:
| 服務 | 用途 |
|---|---|
| Workers | 後端運算進入點,處理所有 /api/* 請求 |
| Workers Static Assets | 供應 Vue 前端的靜態檔(等於免費全球 CDN) |
| Durable Objects | 對戰房(Battle Room)與大廳(Lobby)的有狀態邏輯 |
| D1 | SQLite 資料庫,存使用者、個人設定、出賽陣容、戰績 |
| R2 | 物件儲存,放打擊音效素材(沒檔時前端自動回退合成音效) |
| Google OAuth | 第三方登入(Cloudflare 之外唯一的外部服務,本身免費) |
資料庫這塊值得一提:D1 就是 Cloudflare 託管的 SQLite,用起來跟一般 SQL 沒兩樣,但完全 serverless、不用自己管一台資料庫主機。使用者帳號、暱稱、操作偏好、3 台出賽陣容、對戰戰績,全部存在這裡。
R2 則是 Cloudflare 的物件儲存(對標 S3),這裡拿來放打擊音效。它有個對「省錢」很關鍵的特性:沒有流量出口費(egress)。一般雲端儲存最容易爆預算的就是「資料被下載出去」的流量費,R2 直接把這筆砍掉了。

所以,到底怎麼做到免費的?
把上面的設計收斂成一句話:這套架構的資源用量,被刻意壓到能穩穩待在 Cloudflare 免費額度內。 拆開來看:
- 沒有常駐的遊戲伺服器:傳統即時對戰要養一台一直在跑 game loop 的機器,這是固定月成本。這裡完全沒有——對戰只在發射的瞬間「算一次」,平常 0 運算。
- 運算量極小:一場對戰只跑一次毫秒級的批次模擬,落在 Workers 慷慨的免費請求額度裡綽綽有餘。
- 頻寬極省:傳 2KB 重跑包而不是 1.5MB 軌跡,差了三個數量級。
- WebSocket 全程休眠:玩家思考時 DO 在睡、心跳由底層自動回應不喚醒它,連線在線卻幾乎不計費。
- 靜態前端走 CDN:Vue build 出來的檔由 Static Assets 全球發送,這部分本來就免費。
- 資料庫與儲存都 serverless:D1 與 R2 都有免費額度,又沒有資料庫主機要養、R2 還免流量出口費。
重點是這些不是事後「省」出來的,而是**「發射定生死、伺服器算一次」這個玩法決策,從根本上決定了它不耗資源**。玩法設計和技術省錢,在這個專案裡其實是同一件事。
結語
回頭看,這款戰鬥陀螺最有意思的地方,是它證明了:一個好的架構決策,可以同時解決遊戲性、防作弊、跨平台一致性,還有成本。 「確定性批次模擬」這一招,讓伺服器只需要在對的時機算一次,剩下交給兩端各自重播——既擋掉了作弊、消滅了浮點數難題,也讓整套線上對戰能舒服地跑在免費方案上。
如果你也在做 side project,又被「上線後的伺服器成本」勸退,很推薦研究看看 Cloudflare 這套 Workers + Durable Objects + D1 + R2 的組合。先想清楚「我的應用真的需要即時運算嗎?」很多時候,答案是不需要——而一旦不需要,免費的空間就大得超乎想像。
下一篇我會切回玩家視角,完整拆解這款戰鬥陀螺的 遊玩規則——雙資源血條、四圍屬性、互剋三角、五大必殺技到底怎麼配。有興趣的話記得追下去。有問題歡迎留言,我們下篇見!