Dealopoly的第一版听起来很简单:玩家拿牌、出牌、收租、偷一两个地产,最后有人赢。作者试着让不同浏览器里的多个人一起玩之后,问题彻底变了。原本看起来只是个卡牌游戏界面,一下子变成了一个附带界面的分布式系统问题。
Dealopoly是一个实时多人卡牌游戏平台,围绕Monodeal和Lowdeck这类游戏搭建。它现在的架构把Web应用、一个权威的Fastify WebSocket游戏服务器、一个确定性的游戏引擎、持久化存储,以及基于Redis的实时基础设施拆了开来。
![]()
作者说,他学到的最有用的东西其实跟卡牌没多大关系,而是关于状态、权威、失败和边界。
谁说了算:浏览器还是服务器
单人游戏里,浏览器可以当宇宙中心。界面知道当前状态,玩家点个按钮,事情就发生了。多人游戏立刻打破这个假设:玩家A的浏览器发出“出这张牌”,请求到游戏服务器,服务器交给游戏引擎,引擎算出新的游戏状态,再分发给玩家A和玩家B的浏览器。
于是有个关键问题:谁来决定这一步是否合法?Dealopoly的答案是服务器。浏览器只是请求一个动作,服务器判断这个动作是否合法,游戏引擎应用规则,产生的新状态再送回给玩家。
这个区分是整个项目里最重要的架构决策之一。拿一个playCard(cardId)按钮来说,很容易让人想先在客户端做点校验,然后乐观地更新本地状态。对普通应用来说,这是完全合理的模式;但对竞技性多人游戏,它制造了一条危险的边界。
一个被改过的浏览器可以直接发来:
- {"type": "playCard", "cardId": "deal-breaker"}
所以服务器必须把每一条命令都当成不可信输入来处理。它会依次检查:玩家在这个房间里吗?轮到他了吗?这条命令合法吗?目标有效吗?当前游戏状态能接受它吗?任何一项不过,就拒绝;全过了,才交给游戏引擎推进到下一个状态。
作者把这个教训推得更远:客户端可以请求一个操作,但拥有状态的那个系统,才应该负责决定这个操作是否发生。同样的原则可以用在支付、库存、权限、工作流系统和协作应用上。
把规则从界面里拆出来
代码库里作者最看重的决策之一,是让游戏引擎独立于界面。引擎不需要知道请求来自哪里,它只接收状态和一条命令,然后要么返回拒绝,要么返回下一个状态和相关事件。
这意味着规则可以在不启动Web服务器的情况下被测试。一个规则测试可以直接跑“游戏状态+命令→游戏引擎→下一个状态”,而不需要走浏览器→WebSocket→服务器→Redis→数据库这一整条链路。系统里最重要的部分因此变得容易推理得多。
卡牌游戏的规则交互数量大得惊人,界面却能让这一切看起来很轻松。Dealopoly的游戏引擎因此为各个领域准备了显式的规则模块:
- setup(初始设置)
- draw(抽牌)
- property(地产)
- rent(租金)
- payment(支付)
- reactions(响应)
- win condition(胜利条件)
这种拆分很重要,因为规则的增长速度往往比界面暗示的要快。一个看似无害的改动,比如“允许另一种卡牌类型影响租金”,可能会波及支付结算、响应、回合状态和胜利条件。把这些规则放进显式的领域代码里,每个改动都有个明确的地方可待。
一次出牌,其实是个小状态机
游戏里比较有意思的部分是响应处理。假设玩家A收取租金,玩家B可以回应,然后玩家A可能还有下一步回应。于是一个动作不再是“出牌→结束”,而更接近:玩家A动作→等待结算→玩家B的响应窗口→响应被接受吗?接受就走反制,不接受就直接结算,反制之后又开一个新的响应窗口,最后才最终结算。
这实际上就是一个小型状态机。作者从中得到的教训同样超出游戏本身:只要一个操作可能暂停、等待另一个参与者、被拒绝或被反制,就应该把它建模成状态,而不是试图把所有东西藏进一个函数里。这样系统会好推理得多。
还有一个问题出现在服务器知道的信息比某个玩家应该知道的多的时候。服务器可能知道每个玩家的手牌,这意味着把完整的游戏状态广播给每个客户端会是个安全问题。Dealopoly因此为每个玩家生成一份针对该玩家的、经过遮蔽的游戏状态视图。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.