游戏后端基础介绍
像是所有理论设计落地到实际模型,游戏需要通过网络模型的语义表达出来,而网络模型的固有性质也会在游戏上有所表征。
网络?一切看起来有所不同
网络游戏,玩家在不同的地点,玩着相同的游戏。一般来说,游戏会运行在中央服务器,服务器将游戏世界序列化成数据流,数据流通过互联网这条信息高速公路运输到各个玩家客户端,客户端根据数据流还原出正确的游戏世界,向玩家展示游戏世界的最新变化。像是所有理论设计落地到实际模型,游戏需要通过网络模型的语义表达出来,而网络模型的固有性质也会在游戏上有所表征。所以设计网络游戏的第一步,是选择一个合适的网络模型角度,分析它所需要的语义,将原有设计解构到各种语义上。对于网络系统固有的延迟、丢包,能做的就是针对性的设计网络传输协议,尽可能向玩家隐藏这种缺陷,在一定的网络容错率下,保证游戏的基本体验。
Entity & Comp & Property
游戏世界由面向对象的方式所塑造,这样与一般认知相符方便后续迭代,除非想要造一艘忒修斯之船。抽象 Entity 的作用就如同 Unreal 中抽象 UObject,我们的所有设计都需要围绕 Entity 展开,它有着自己独立的生命周期、随着生命周期变化自动管理的资源 RAII、存盘落地通信消息分发。这同样契合服务端 Actor 模型, Entity 无论是与 Entity 打交道,还是请求 Service 的服务,两者都遵循相同的编程范式,扮演各自的角色。
软件设计中有一句话,组合优先于继承。一个 Entity,它有着场景位置信息、碰撞盒信息、以及自己的形状,就像三维空间物体在平面上的投影。在理想的设计中,Entity 就像是一个高维空间物体,我们尽力描述它在低维空间的投影,将这些投影一个一个的组合起来,就得到物体真正的形状。
现实情况是各个投影方面并非正交,可能彼此之间存在依赖关系,如寻路肯定依赖于场景位置信息;可能游戏行为同时影响了多个维度,需要编写一些粘合代码,过程琐碎且难以复用。这类问题跟软件开发中包管理相似,游戏开发往往是业务敏捷导向的,不太可能为此开发包管理工具,组件在跨越项目的时候组件复用价值几乎为零。由于是人工管理这种复杂度,所以经常可以看到在项目中一般都是先编写一些基础大类,如 Avatar、NPC、Monster,再基于这些大类派生自己的专属逻辑。适当在能力范围内拆分较少组件,避免盘丝洞一般的依赖地狱,组件全家桶的悲惨结局。
另一个使用组件需要注意的是,我们使用组件希望达到什么目的?这些组件真的是同一个逻辑层次的抽象吗?组件是实现组合机制的一种手段,实际上如何按照何种方式组合依旧需要具体去指定。Unreal、Unity 这样的框架使用不同组件去分散不同方面的功能,具体组合的机制则是使用组件树去组织,传统意义上的服务端则很少会在一个 Entity 上做这种父子结构,基本就是单层扁平化组件加特定胶水代码粘合,因为很少会有针对父子结构的逻辑,有一般也是静态配置阶段就决定的。这样逻辑层扁平化的组件跟 Unreal 中的组件显然不处于一个对等的逻辑层级。
Entity 属性数据是游戏开发中的核心关注点之一,开发者无时不刻在与各式各样使用场景下的 Entity 属性数据打交道。玩家数据需要落地存盘,在下次读取的时候需要拉起游戏逻辑到正确的游戏状态;状态同步要求将各种逻辑状态表现关键帧序列化,或可靠的传输至相关玩家客户端以供正确显示;编辑器开发者则需要标注额外的元数据,用以在策划编辑时提供相对应的提示帮助,做编辑分组引用静态数据导出等等。总的这些问题导致 Entity 的属性不像是面向对象中一个简单的值,故单独起名叫做 Property。后面也将看到,Property 的处理伴随着游戏开发的始终,琐碎但重要。因此一般的解决方案就是声明式的去封装标记,利用反射扫描或主动注册自动化,分发到各个业务端具体情况具体处理。
Space & Migrate & Route
Space 是房间、场景的抽象,大部分游戏逻辑都是发生在其上。它是一种共同上下文,同时也是场景逻辑的承载体,隐含的意思 在于同一个场景下 Entity 之间是可以直接进行操作的,即处于同一个进程上,无需考虑丢包事务顺序容灾。为了达到进入同一个进程这个目的,玩家数据需要从数据库加载或从其他服务器迁移,路由发送到 Space 所在的进程上,并且同时将玩家连接迁移过去,因此 Migrate 与 Route 是网络游戏必不可少的。
如同 kubernetes 调度 docker 以运行应用程序一般,游戏中有 Service 分配均衡 Space 以供 Entity 执行游戏逻辑。一个 Entity,起初只是一份单纯的数据,在进入场景之后,展开游戏相关逻辑,对场景及场景中的其他 Entity 产生影响,在离开场景的时候,回收游戏相关逻辑拥有的资源,序列化回原来的数据形式。重复这样的双端异步等待流程,Space 按需分配,Entity 则可以在 Space 中漫游。这种漫游一般被叫做 Migrate,涉及到服务端迁移方、服务端目的方、客户端三方,异步协商中间的状态处理比较复杂,本质上是客户端连接进程间的转移。对于轻度化的开房间类游戏来说,可能直接匹配服务器到战斗服务器的一条消息就相当于进行一次 Migrate,而对于重度 MMORPG 游戏来说,可能在战斗过程中同时就在进行 Migrate,对服务端逻辑拉起能力有更高要求。
世界在变化,网络在流动,路由是网络系统的核心之一,它是网络中的路标,让信息流动到指定的地址,提供可能找到 Service、Space、漫游 Entity 这样 Actor 的一种方式。设计一个好的路由,可以让信息根据路由高效流通、可以更好了解集群情况以服务治理。需要考虑的是容灾,一般出于一致性高效简单维护考虑,路由信息不会做冗余。一旦存放路由信息的机器宕机了,所有基于此的通信就全部失效了,同时由于游戏里的 Actor 一般是持有状态的,其还承担分布式锁的责任,无法跟踪对应 Actor 状态,意味着将面临一致性与可用性的取舍。
RPC & Order
网络是有层级的。对应到游戏这种应用层,自然是 Entity 端到端的消息通信,符合一般游戏开发的认知,毕竟直觉上服务端上的一个逻辑对象跟客户端的一个显示对象是存在一一对应关系的。更抽象的来说,Entity 就是网络模型中的一层应用层,RPC 则是联系双端逻辑对象显示对象的桥梁,这两个对象是在运行时创建产生一一对应关系的,双端协议是相互对应的,应该冠以类型予以静态检查,而 Entity 本身类型既是极好的标注,于是 RPC 便常常与 Entity 类型绑定在一起,作为端到端的通信基本单位,本身是不可细分的一层类型协议。
游戏世界中需要通知对端发生的所有事情,都会被转述为 RPC。所以构建的第一个问题,就是自动同步 Entity 这种基本单位,后续 Property 修改、坐标位置变更、通知请求决策都会由 RPC 流所构建。可以将 Entity 想象成一支画笔,在纸上画出的线条是 RPC 流,其中顺序很重要,Entity 做出某个 RPC 决策的基础可能在于前面 RPC 使得前置条件被满足,它是根据前面画纸上的线条来决定下一笔的。我们可以放弃这种顺序性来降低延迟,例如位置同步走额外不可靠的 RPC 信道,以不等待重传换取及时性。也可以拓展这种顺序性,对于 MMO 这种有 AOI 视野裁剪、跨进程交互的游戏来说,是以 Entity RPC 为偏序,整个世界没有 RPC 全序的。而对于房间类游戏来说,单个房间内一般都是有序的,好处在于,你可以毫无顾忌的以房间中的状态去做决策编写代码,而无需像 MMO 里面一样建立起严格的生命周期流程,保证我们所有的决策都是位于严格的生命周期状态下的,以灵活性换取逻辑架构的简单性。有的时候 MMO 的偏序关系是相对反直觉的,对玩法设计还有逻辑分层都会有所影响。
同步逻辑流 & 处处可重建 & 双端生命周期交叉
构建 Entity 的入口有两个,一个是从零开始由静态数据开始构造,还有一个是从运行时数据开始构造。前面一种情况比较平凡,后面一种情况比较复杂,发生在很多场景下,因此需要来探讨下必要性、需要满足的约束及影响。
在这节之前,我们讨论的都是服务器上面的逻辑流,但实际上客户端也会有一条单独的逻辑流,它是服务端逻辑流部分子集的拓展,客户端服务端的 Entity 运行时构建入口含义不同是需要严格区分的。
客户端的逻辑流可能基从服务端逻辑流的任何一点开始构建,因为客户端可能在任意时刻重连游戏。首先需要拿到那一点服务器逻辑流快照,然后缓存下需要发给该客户端的 RPC,待客户端从快照恢复了它应有的状态后,再执行缓存住的 RPC。这个过程中,服务端的逻辑流应该是处处可重建的,服务端给定的任意逻辑流数据快照,对于客户端 Entity 的运行时构建都必须是合法的,不能只考虑初始状态,无法展开中间状态对应客户端逻辑。
只有服务器上发生的事情,才是真实可信的。这句话不只是在强调安全性,要求不能直接置信客户端,同样也是在说,客户端的逻辑状态随时都可能丢失,或许玩家重新进入游戏,进行一次大的断线重连,甚至是重新换了一台硬件登录。客户端的运行时构建入口理想上应该像是编程语言概念中的纯函数,服务端同步相同的逻辑流快照,客户端还原出来的逻辑流应该是一致的。如果客户端需要展开仅存在于客户端的额外逻辑,并且存在存盘或同步要求,像全量同步 ARPG 中正在技能动画中的一位角色,都必须在这部分同步逻辑流上有所体现。
为什么叫逻辑流而不是叫数据流呢?因为除了通常所见的同步状态之外,实际上往往还会同步一些 RPC 用于指导客户端展开部分对应的逻辑,一般名称如 on_login_in 的函数就是做这样的事情,它一般编写在服务端,在客户端进行一次全量逻辑流同步时被调用。不用状态做主要是因为有部分逻辑比较灵活,可能有的无法从中间插入同步,需要清除部分服务器状态,完全用状态模式去做比较重度且死板。联系前两段尾处中提到的合法状态与同步逻辑流,其大概关系就是 on_login_in + 合法状态 = 同步逻辑流。
生命周期之所以能给编写代码提供一个强保证,很大因素在于它可以维持一个“当且仅当”的语义元素。即当在生命周期覆盖下的时候一定是有效的,且生命周期覆盖了整个有效时间。在网络游戏架构下,双端的统一概念的生命周期是有关系但不一致,可能 Entity 一次服务端生命周期中会经过若干次对应客户端生命周期。需要考虑逻辑真正需要建立在哪端哪些“当前仅当”下才能生效,很多时候根据生命周期关系找到一个合适地方可以避免很多无谓的烦恼。
同步方式 & latency & consistency
首先需要澄清一下公认概念,状态同步与帧同步是一组相对的概念,lockstep 则主要侧重于一种游戏实现机制,多客户端同时在一个回合提交指令,服务器收集指令同步前进,更多资料可以看 关于 “帧同步”说法的历史由来 。纯粹的帧同步主要是,游戏由一系列固定的回合指令构成,确定性的初始状态加上确定性的回合指令得到确定性的结束状态。纯粹的状态同步主要是,游戏最终表现为对象各种状态,通过快照形式差量同步状态属性达到外在一致。
个人觉得,重要的是服务器下发给客户端的同步方式是状态还是指令。下发状态偏向的是外在一致性,注重结果,双端的内在逻辑可以不一致。下发指令偏向的是模拟,注重过程,可以有如同单机一般的反馈体验。这里的如同单机一般的体验建立在两点的基础上,一是客户端有更多关于上下文表现输入内在语义状态的指令,真实模拟碰撞打击,严格的游戏逻辑时序关系,得到更好的表现层效果。二是抽离动态玩家输入与静态玩法逻辑,在玩家运行时输入状态连续且人数较少的情况下,先行预测下一帧状态成功率高,尽可能地先行隐藏延迟。
帧同步状态同步并非是相互对立的关系,只是同步方式选择天平的两端。对于帧同步,一般会引入状态同步这样的快照机制做快速断线重连。对于状态同步,一般会对部分技能采用指令同步的方式,本地根据指令状态计算表现,然后在关键帧作误差校正。就在 C/S 架构下的通用性而言,状态同步是要更胜一筹的,帧同步以一种拉普拉斯妖的存在,依赖于前序操作强一致的确定性,而玩家的输入,始终是不可避免的运行时变量。
最终,同步方式只是决定如何去描述对局逻辑,无法掩盖网络游戏固有 latency 跟 consistency 之间的矛盾。我们需要从客户端玩家的操作视角,在不影响公平性的前提下,尽可能的去优化表现。这需要根据玩法,因地制宜的进行优化,比如对于玩家自己的行动,动画演出需要先行,防止操作上的粘滞感。对于他人的行动,需要给移动添加惯性,减少位置突变,增加技能前后摇,给予反应时间,让游戏玩法体验不会对延迟跳动极度敏感。
在 FPS 游戏中,会有 peek advantage 一说,即突破手的移动行为传输到架点手客户端显示出来需要经过网络延迟,因此会比突破手 (peek) 反应慢一拍。在 csgo2 中,官方引入了 subtick 机制,即依赖客户端操作的精准时间戳去裁定击杀游戏环境。等价于拿突破手更好的 latency 体验而忽略双方窗口时间的 consistency,加上服务端延迟补偿回滚击杀,判定阈值较为宽松的时候,会进一步放大这种 peek 优势,在一定程度上这影响玩家的决策,鼓励更多的 peek 而非架枪,这些调整都需要结合玩法需求具体权衡。
玩家 & Controller & Authority
玩家是游戏世界的参与者,而不只是观察者,其中权限管理是很重要的一环。其一在于玩家通过 RPC 请求主动与游戏世界交互,玩家决策空间即合法请求空间的子集,其二在于,对于异步的双端网络通信请求,不能保证客户端认为合法的请求在到达服务端依旧合法,也有相应的反作弊需求。这个过程中,有两个重要概念,Controller 与 Authority。
Controller 的概念来自于“我”,指代的是一个虚拟控制器,玩家或者 AI 可以借此影响游戏客观世界,所有从 Controller 视角出发的逻辑皆为主观,体现了玩家的主观能动性。对于客户端的 avatar,其他 avatar 跟自己控制的 avatar 是有不同的,其他 avatar 同步的只是外在一致性,扮演 ROLE_SimulatedProxy 角色,而自己控制的 avatar,出于手感与乐观锁一样的考虑,会有客户端先行,赋予 ROLE_AutonomousProxy 这样的角色。所以客户端为”我“的玩家,实际是 ROLE_AutonomousProxy 与 Controller 的结合。通常只有我自己才能做出选择,因此只有 Controller 拥有代表客户端向服务器调用 RPC 请求的权限。
Authority 的概念来自于一致性要求,指代的是权威的上游唯一数据端,只有经过 Authority 的消息才是唯一可信直接执行的,因此负责认可请求合法性,确保一致性。通常的情形是,服务端逻辑流作为 Authority,客户端同步构建出一份客户端逻辑流,这个时候相当于做了一份状态拷贝,客户端持有 Controller 根据客户端的拷贝状态做出请求,Authority 仲裁合法性,影响真正的权威数据。
做这样的概念区分,是为了注意到,在网络游戏中,Controller 到 Authority 可能是一个潜在异步过程,需要额外的代码来处理请求失败,对于服务器上 AI Controller 来说,情况 fallback 到跟与单机一致。而如果说,每种类型 Entity 都绑定固定操作空间的 Controller 的话,那像个人任务、背包道具之类第一人称的东西,就像是一个单独承载“我”语义的 Controller,玩家最终得到的 Controller 则是两者的混合。
相关性 AOI
服务端往往拥有全局性的数据,同步给客户端显示的是经过筛选的局部信息,就像是大树上的一根树枝。需要同步哪些信息给客户端,成为了一个需要考虑的问题。通过规则、Tag 去筛选出需要的同步的基本单位—— Entity。或者通用、视野相关的方案,称之为 Area Of Interest,位置移动是高频事件,也涉及到网络 IO,是性能开销大户,主要从单位场景管理、标签、更新频率、视野关注的双向关系上着手优化。也需要保证客户端影响关心的 Entity 都在 AOI 范围之内,不然客户端无法执行相关玩法逻辑,因此更多还是要聚焦玩法需求。
同构异构
编写可移植代码的难点在于,确定代码执行依赖于哪些上下文与环境性质,以及出于性能原因、抽象泄露、边界情况等不可抗力因素,写多份特例化代码与后续打各种补丁。游戏开发中,对于游戏逻辑代码可移植性要求是比较高的,需要整体把握游戏架构及合理拆分业务边界,一段代码可能会分别在客户端、编辑器、服务端环境下运行。MMORPG 这样类型的游戏玩法倾向于多人大世界,双端拆分服务端重逻辑客户端重表现,玩法逻辑重复率低各自编写,称为是异构。而一般帧同步游戏由于采用各自模拟的模型,因此帧同步指令执行是处于双端环境下的,玩法逻辑基于构建在一套抹平差异的语义上,称之为是同构。
固然像 AOI 那样场景管理不太可能被照搬到客户端,子弹时间这种设计也不太可能会被搬到多人游戏中,类似 Controller 与 Authority 关系代码都得当成异步编写,需要注意代码运行环境,付出额外代码去应付潜在情况、做 Mock、做 Guard。但这样把核心游戏逻辑当成 sdk 来编写,带来的好处也是巨量的,核心在于可以灵活按需将游戏逻辑交给客户端或服务端运行。对于偏向单机向内容,可以让客户端自己独立运行,服务端只做一些数据记录校验,减少服务器成本的同时还能无网络延迟提升表现力,而对于一些需要强校验 PVP 场景,则可以又回到服务器模拟。对于房间类类帧同步确定化的游戏,回放系统使用指令回放也会要求客户端可以独立演绎服务端指令,反作弊系统也可以后台抽取对局做离线校验。对于编辑器来说,经常会需要预览功能,策划需要高效迭代,如果是异构,则在编辑环境下还需要连接服务器,架构复杂度增加,带来麻烦。即使对于联机向游戏,服务端委托客户端做模拟,或者客户端掉线也可能得回落到服务端做基本模拟。面临上述情景,已经异构化的框架往往也只能再另起炉灶,无法复用代码,对于后续更改迭代,都算是更重的负债。
Monad
在王垠 《对函数式语言的误解》中,是如此解释 Monad 的:
Haskell 引入了一种叫 monad 的概念。它的本质是使用类型系统的“重载”(overloading),把这些多出来的参数和返回值,掩盖在类型里面。这就像把乱七八糟的电线塞进了接线盒似的,虽然表面上看起来清爽了一些,底下的复杂性却是不可能消除的。
Maybe 也是一种 Monad,它表达的式一个值的有无,对于一个类型 int,Maybe<int> 则表达可能是 None 或者是 int。很多时候我们在编写游戏逻辑的时候,就像是从一个 int 范畴的世界迈向 Maybe<int> 范畴,其中各种抽象泄露语义表征为 None 开始溢出,代码需要面对各种 None 做处理,编写代码的过程即加深认知的过程,直到最后,总结出一套框架,称其为游戏服务端解决方案。萌新看到这样的庞然怪物,自然也会惊呼,跟想象中的不太一样更加复杂。但就像是二十世纪初,物理学从牛顿力学推广到相对论,科技发展是渐进式的,相对论的出现并不妨碍牛顿力学的使用,在人类宏观视角下,相对论可以退化近似为牛顿力学。至少,代码编写不像是数学公式演算那般枯燥理性,很多时候直觉上的判断还是十分有效,框架语义在简单情况下 Fallback 成相对平凡合乎直觉,可以根据性质推理并编写游戏的 Monad。