Circuits 是 Rec Room 用於編程房間的技術:一個多人同步的即時腳本系統,讓玩家可以共同建構互動體驗。打造 Circuits 是我職業生涯中最榮耀的成就之一。我想分享我們如何解決核心網路挑戰,希望能幫助到正在處理類似問題的任何人。

在我們今天所知的 Circuits 之前,我們有「Circuits V1」。Circuits V1 由才華洋溢的 Jamie McDuffee 在幾週內建構完成,重用了我們的物件同步技術。對於物件而言,同步的單位是遊戲物件,因此數千個腳本元素需要數千個遊戲物件。這導致記憶體和 CPU 的擔憂,限制了複雜度的上限。

這種取捨是刻意的。我們當時不知道 Circuits 是否會成功,因此快速發布並測試假設比完美的架構更重要。Circuits V1 取得了巨大的市場成功,並支撐了 Rec Room 數年的所有腳本功能。我們了解到,結合 3D 建模、基礎腳本和熱情的社群,自然會產生有趣的 3D 遊戲。但在將產品擴展到極限後,我們需要不同的策略,一次重寫,我們稱之為 Circuits V2。

對於 Circuits V2,一個關鍵的決策降低了複雜度:如果物件是同步的昂貴部分,那麼如果所有 Circuits 都是一個遊戲物件呢?從這個前提開始,我們在頂部建構了 Circuits V2 同步模型。我們為此進行了兩次嘗試:一次是我們發布的 MVP 版本;另一次是在零售版中發現問題後建構的。

如果你不小心處理行更新,你會看到錯誤。

簡單的操作可以透過 null 檢查來修正,但隨著玩家和操作數量的增加,我們的點對點網路模型同步複雜度也隨之增加。由於訊息會發送給每個玩家,並且以衝突的順序到達,行很容易失去同步。我們探索了交易、冪等性、最終一致性的共識演算法。這些都不是無法克服的工程挑戰,但這並不是新創公司發現價值所在的地方。

我們找到了一個解決方案,其靈感來自流行的 JavaScript 函式庫 Redux。Redux 的核心理念是,你的整個應用程式狀態都存在於一個物件中,而改變它的唯一方法是透過一個稱為「reducer」的純函式傳送一個動作。Reducer 接收當前狀態和一個動作,並返回新的狀態。

我們保留了 Circuits 資料庫,但將其重新構想為一個全局狀態。現在,任何狀態的變更都可以由 reducer 函式 f(S1, A) = S2 來表示,其中 S1 是原始狀態,A 是我們想要執行的某個動作,S2 是新狀態。

我們利用 Circuits 使用單一遊戲物件的事實,並使用單一 RPC 方法進行所有網路通訊。這意味著我們可以在一個地方管理所有網路通訊,而不是為每個變異都擁有專用的 RPC。

我們使用 Google 的 Protocol Buffers 來定義動作,這是一種可序列化的格式,我們可以在網路上傳送:

我們使用流暢的介面來註冊頂層的響應:

透過這種設置,我們可以輕鬆實現 Reduce 函式:

所以我們的資料存在一個資料庫中,該資料庫被建模為類似 Redux 風格的可歸約狀態容器。還有最後一個問題需要解決才能將這一切結合起來:玩家可以點對點傳送動作,因此點之間的延遲可能導致玩家看到動作的順序不同。A 發送給 B 和 C,而 B 發送給 A 和 C。A 會認為他們的動作先發生,而 B 會認為他們的動作先發生。

我們再次選擇了一個簡單的解決方案:

這為 Circuits 創建了所謂的「可序列化隔離級別」。Designing Data-Intensive Applications 對可序列化隔離的描述如下:

我們通俗地將此解決方案稱為「動作漏斗」。我鼓勵大家想像一個模型,將所有動作都扔進一個漏斗裡,然後當它們到達底部時,一個接一個地「滴」出來。

動作漏斗是成功的。在物件方面,我們仍然為各種操作使用專用的 RPC。這會導致同步缺陷,必須逐案解決。由於 Circuits V1 是建立在物件網路之上的,因此它容易受到這些缺陷的影響。

在轉移到 Circuits V2 的動作漏斗後,這些缺陷消失了。在極少數情況下發現缺陷時,我們可以在一個地方修復它,而不是在程式碼庫中搜尋原因。我們在這個元件中的缺陷率接近於每 2 年 1 個缺陷。簡單的程式碼也意味著團隊中的每個人都可以貢獻解決方案,即使是經驗很少的新大學畢業生。

除了我們直接針對的問題外,我們還發現許多其他問題在更簡單的程式碼庫中變得微不足道。讓我們來看看其中一些。

加入進行中是指玩家加入一個會話,而其他玩家已經建立了一些新東西。玩家加入時會下載儲存資料,但其中不包含進行中的建立資訊。這意味著加入的玩家必須重播網路狀態或「快轉」以趕上進度。

為了透過動作漏斗解決這個問題,我們會定期傳送一個特殊的 InitializePayloadData 動作,其中包含房間的最新儲存資料。我們不會將其上傳到雲端儲存,而是將其儲存在玩家連接的即時多人遊戲會話伺服器中。隨著玩家的建立,在 InitializePayloadData 動作之後,其他動作會儲存在會話伺服器中。當我們達到某個閾值(最初是 25 個動作)時,我們會建立另一個 InitializePayloadData「快照」並將其儲存在伺服器中,同時清除佇列。

加入的玩家會透過簡單的邏輯趕上進度:

由於我們的網路程式碼依賴於單一 RPC,因此我們可以更新單一方法來轉換流經它的所有內容。我們在生產環境中使用兩種修改:壓縮和分割。它們看起來像這樣:

透過在轉換期間更改負載,我們可以處理頻寬是問題的大型動作。CompressIfOversized 會檢查動作的大小,如果太大(> 約 100KB),則運行 zip 壓縮,並返回帶有此負載的新動作:

如果動作仍然太大(> 約 300KB),我們會使用 SplitIfOversized 進一步縮小它,它會返回一個帶有這些負載的動作陣列:

每個玩家都有一個唯一的 player_id 並追蹤一個本地 action_id。這種組合創建了每個會話唯一的金鑰。玩家將這些動作儲存在類似 Dictionary < ( int PlayerId , int ActionId ) , List < byte [ ] >> 的結構中。當列表的計數等於負載的 part_count 時,動作會被重新組裝並像任何其他動作一樣分派。雖然我們可以在第一個部分中一次性傳送計數,但我們選擇每次都傳送它以簡化某些調試場景。

這些技術幫助我們繞過了網路限制,這些限制對玩家在創作中使用的總節點數量設置了上限。

日誌記錄和自動化也受益於單一程式碼路徑。我們提供掛鉤來捕獲每個傳送和接收的動作。接收掛鉤看起來像這樣:

WriteToLog 會將從網路接收的原始位元組寫入日誌檔案。動作日誌會在崩潰時或玩家按下遊戲內按鈕時上傳。動作日誌有助於兩種調試技術:

每個玩家的 Circuits 實例的狀態在動作流的任何一點上都是一致的。換句話說,當傳送動作 A -> B -> C 時,如果每個玩家在接收 B 後雜湊他們的狀態,則雜湊必須相等。我們利用這一點來實現我們的可觀察性堆疊。

這個可觀察性堆疊有助於我們解決零售版中的同步缺陷。遙測數據包括缺陷遊戲的 ID。我們可以拉取日誌查看出了什麼問題,或聯繫創作者了解該場景的更多資訊。

我們設計的一個重要部分是故意不去解決某些問題。我們希望投入少量努力就能完成大量工作,因此我們樂於選擇那些能讓我們有時間改進 Rec Room 中其他更重要部分的工件,只要玩家有前進的道路。

在決定是否解決缺陷時,我們決策框架中的一個常見原則是「用社會解決方案解決社會問題」。如果一起工作的人可以圍坐在一起思考一個問題,我們能否依賴這種社會推理而不是技術?

我將討論我們如何利用這一原則來找到簡單的解決方案,解決那些原本需要複雜技術的問題。

一些異常情況會導致動作丟失。例如:主客戶端斷開連接。Rec Room 中一些最常見的缺陷與主客戶端斷開連接或轉換有關。從動作漏斗的角度來看,只有主客戶端可以向所有人發送動作。當主客戶端更改時,我們將簡單地丟棄動作並向發送者返回失敗。

在 Rec Room 中,主客戶端斷開連接是顯而易見的。通常會有一個卡頓或網路中斷,如果你正在與某人一起建立,他們會消失。由於斷開連接期間已經發生了奇怪的事情,我們認為沒有必要解決額外的奇怪事情:你已經處於現實暫停的狀態。

作為一個具體的例子,如果你傳送一個 AddNodePayloadData,一個玩家斷開連接,你看到節點消失了,那麼重新建立它就足夠簡單了。損失的工作僅限於少量最近的動作。

我們對具有複雜雜湊機制的檢測和重播丟失動作的方法進行了一些早期思考,但最終我們決定直接丟棄它們。當我們可以擁有一個更多人可以訪問的程式碼庫時,複雜的演算法就變得不值得了。你添加到系統中的每一個技巧都會消耗認知預算,當你做一些新的事情時,你必須同時考慮所有技巧。

另一個透過社會解決方案更優雅地解決的場景是不同人對同一個物件進行的快速編輯。這個場景的結果是物件可能會來回跳動,運動會同步,本地的模擬物件也會更新。我們有一個鎖定物件的機制,但同樣,複雜性使其成為一個很少使用的工具。

相反,我們考慮了這個場景的普遍性。在 Google 文件中,你多久會同時編輯同一個段落?我們在 Google 文件中做了很多工作,這似乎並不像會發生的事情。人們編輯了文件的不同部分,或者人們一起討論文件的某一部分,而一個人正在編輯它。

Google 文件的即時編輯體驗與我們在 Rec Room 中看到的行為相似。同時編輯 Circuits 腳本相同部分的情況很少見。如果他們這樣做了,他們傾向於一起討論。如果你在爭論一個節點應該放在哪裡並同時移動它,那麼在技術問題出現之前,你就有一個社會問題需要解決。

因此,我們將由於快速編輯導致的延遲一致性問題排除在值得用技術解決的問題之外。

我們同步工作的潛在因素是這樣一個賭注:簡單的程式碼在長期來看優於巧妙的程式碼:將遊戲物件合併為一個,透過單一擁有者傳送動作,丟棄動作而不是重播它們。這個賭注得到了回報。由於每兩年大約有一個缺陷率,一個對新畢業生來說易於訪問的系統,並且足夠靈活,可以在不進行結構性更改的情況下吸收壓縮、分割、可觀察性和加入進度,因此我們可以將時間花在產品上,而不是追逐技術。

如果有一件事我希望你能從中學到,那就是多人系統的最佳架構並不總是最高級的。有時,它是對你的整個團隊來說足夠容易理解、足夠無聊以至於很少出錯,並且足夠小以至於在出錯時可以在一個地方修復的架構。

我們的 CEO Nick Fajt 用一句話概括了這一點,這句話一直讓我印象深刻: