我是如何设计一款抗滥用、容错、零成本的多人在线游戏的
大约一年前,我为我开发的开源网页农场游戏《Farmhand》部署了多人游戏功能。自部署以来,该多人游戏系统从未出现过任何宕机或服务降级的情况。最棒的是,我没有支付任何服务器托管费用,因此我可以让其他人免费畅玩。本文将概述我是如何从零开始设计这个系统的。
游戏
简而言之,《农场主》是一款融合了农耕和市场机制的游戏。游戏的目标是低价购入种子,播种、收获,然后高价出售。价格每日波动,因此你需要谨慎做出买卖决策。
《农场主》最初设计为单人游戏,种子/作物的价格在每个游戏日开始时随机生成。有一天,我突发奇想,如果创建一个全球玩家都能参与的共享在线市场,那该有多棒。我的设想是,每个玩家的买卖决策都能影响全球市场,进而决定所有联网玩家的种子/作物价格。
为了让这个市场体系有趣,它必须简单可靠。我给自己设定了以下限制条件:
- 零托管费用。我从 Farmhand 不赚钱,所以不想花钱托管它。
- 尽量减少运维方面的参与。Farmhand 只是我的业余爱好,我还有一份正职工作。我不想在工作日(或半夜)处理服务中断的问题。
- 容错性和抗滥用性。如果你把服务放到网上,就要预料到有人会滥用它。我希望这个系统不仅要有高可用性,还要能抵御恶意攻击。
最终,我成功开发出一个有趣且功能齐全的多人游戏系统,并且符合所有这些限制条件。
这项技术
这个系统由几个部分组成:
客户
Farmhand 是一款运行于网页浏览器中的PWA应用。客户端的整体架构不在本文讨论范围之内,但就在线多人游戏而言,它使用Trystero和WebTorrent 匹配策略来连接玩家。它通过 REST API 与中央市场服务器进行交互。
服务器
Farmhand 的 API 托管在Vercel 的 Hobby 层级上。Vercel 提供了一个优秀的无服务器平台,该平台提供可扩展的运行时性能,以及静态文件托管、自动预览构建(非常适合测试 PR)等功能。
基于 Vercel 的 API 由 Redis 实例提供数据“持久化”支持。“持久化”之所以加引号,是因为数据始终存在于内存中,因此系统故障会导致数据完全丢失。然而,应用程序逻辑的设计使得这种故障成为一种特性而非缺陷。该 Redis 实例托管在Redis Labs 的免费套餐中。
对于 Farmhand 而言,Vercel 和 Redis Labs 都配置为在 AWS 中运行。
系统架构
玩家随时可以访问https://www.farmhand.life/,打开“在线游戏”开关,然后加入自己选择的房间(global默认情况下)。此时,会发生两件事:
- 向一个无服务器函数发出请求
GET https://farmhand.vercel.app/api/get-market-data?room=global。该请求会检索最新的市场数据,并通知服务器玩家的在线状态。此请求会重复执行,作为心跳信号,直到玩家离开房间,以维持与 API 的“活跃”会话。 - 客户端会与 WebTorrent 追踪器建立 WebSocket 连接。追踪器随后会将客户端连接到所请求房间中的其他客户端。这种点对点连接会一直保持,直到玩家离开房间。Trystero 会抽象化处理这些复杂性。
该 API 管理存储在 Redis 中的房间数据。当发出GET https://farmhand.vercel.app/api/get-market-data?room=global请求(来源)时,API 会检查与键关联的值是否room-global存在。如果不存在,则会初始化该值。以下是一个房间对象示例:
{
"activePlayers": {
"4a793fe2-9eb1-4041-935b-5caf55177dde": 1640727668293,
"58f90cc1-1089-4394-a7e7-2f079f87ed4d": 1640727669934,
"b26b2d59-79f5-40f3-bc91-cfc0554bb994": 1640727674791,
"d1e34686-925e-4344-b7cb-e15ce6d7dad3": 1640727667860
},
"valueAdjustments": {
"asparagus": 0.6798235686529905,
"asparagus-seed": 0.9797840434970977,
"carrot": 0.5382522777963925,
"carrot-seed": 1.1233740954422615,
"corn": 1.1524067154896047,
"corn-seed": 1.2309158460921086,
...
}
}
activePlayers这是一个映射表,其中包含客户端通过UUID确定的唯一玩家 ID 和他们上次发出GET https://farmhand.vercel.app/api/get-market-data?room=global请求的时间戳。每次调用该函数时,它都会检查该映射表,查看哪些时间戳早于预设的HEARTBEAT_INTERVAL_PERIOD10 秒(当前为 10 秒),并删除所有过期的时间戳。这些数据会返回给客户端,同时也会写回 Redis 以确保在函数调用之间持久化。这就是跟踪房间活跃参与者的方式。
valueAdjustments是房间当前市场的状态。地图上的图例指的是游戏中的物品 ID,而数值则代表它们在游戏中的市场价值。市场价值介于 00.5和1 之间1.5,并会根据玩家的活跃度上下波动。当玩家结束一天的游戏时间时,系统POST https://farmhand.vercel.app/api/post-day-results会向 API 发送一个请求,请求的有效负载类似于:
{
"positions": {
"carrot-seed": 1
},
"room": "global"
}
positions代表玩家在最近一个游戏日内增加或减少的所有物品库存。1表示玩家增加了对应物品 ID 的库存(例如购买种子或收获作物),这会提高该物品的市场价值。表示-1玩家减少了库存(通常是出售该物品),这会降低该物品的市场价值。以下是该逻辑的来源:
const applyPositionsToMarket = (valueAdjustments, positions) => {
return Object.keys(valueAdjustments).reduce(
(acc, itemName) => {
const itemPositionChange = positions[itemName]
const variance = Math.random() * 0.2
const MAX = 1.5
const MIN = 0.5
if (itemPositionChange > 0) {
acc[itemName] = Math.min(MAX, acc[itemName] + variance)
} else if (itemPositionChange < 0) {
acc[itemName] = Math.max(MIN, acc[itemName] - variance)
} /* itemPositionChange == 0 */ else {
// If item value is at a range boundary but was not changed in this
// operation, randomize it to introduce some variability to the market.
if (acc[itemName] === MAX || acc[itemName] === MIN) {
acc[itemName] = Math.random() + MIN
}
}
return acc
},
{
...valueAdjustments,
}
)
}
更新后的市场数据再次被保存到 Redis 中。
虐待缓解
这种服务器端逻辑的优点之一在于它能自然而然地减少滥用行为。虽然没有任何机制阻止人们通过自定义POST https://farmhand.vercel.app/api/post-day-results请求来操纵市场,但一旦任何物品的调整后价值达到上限或下限(分别为1.5或0.5),该物品的价值就会在这些范围内随机波动。因此,尽管不法分子可以操纵市场,但市场很快就会自我重置并恢复平衡。即使在这种情况下,对玩家而言,也只会呈现出正常的(尽管波动性可能略大)市场动态。
容错性
Farmhand 的房间数据仅通过 Redis 存储在内存中,不会写入磁盘。因此,它本质上是短暂的。这种设计最糟糕的情况是,由于 Redis 服务器关闭或FLUSHALL执行命令等原因,房间数据丢失。然而,由于API 会初始化请求的房间数据,而这些数据此前并不存在,因此对用户而言,这只会表现为轻微的市场波动,用户很可能不会注意到。
同伴互动
基于 Vercel 的 API 可以有效地管理共享的市场数据,但我希望玩家能够了解还有哪些玩家在和他们一起玩,以及他们的行为如何影响其他玩家的游戏体验。这就是点对点通信发挥作用的地方。
它不使用服务器和逻辑作为客户端之间的中介,而是像之前解释的那样,通过 Trystero 和 WebTorrent 直接相互连接。当玩家执行各种操作(例如买卖物品)时,消息会广播给所有已连接的对等节点,并在“活跃玩家”模态框中显示:
为了减少滥用行为,玩家名称是根据玩家 ID (稳定)通过简单的哈希算法生成的。
export const getPlayerName = memoize(playerId => {
const playerIdNumber = playerId
.split('')
.reduce((acc, char, i) => acc + char.charCodeAt() * i, 0)
const adjective = adjectives[playerIdNumber % adjectives.length]
const adjectiveNumberValue = adjective
.split('')
.reduce((acc, char, i) => acc + char.charCodeAt() * i, 0)
const animal =
animalNames[(playerIdNumber + adjectiveNumberValue) % animalNames.length]
return `${adjective} ${animal}`
})
回顾性分析
到目前为止,这套系统运行良好。自近一年前系统上线以来,Vercel 和 Redis Labs 都提供了卓越的性能和可用性,考虑到我使用的是这两个服务的免费套餐,这一点尤其令人印象深刻。容错和滥用缓解措施使得维护工作量降至最低,这正是我所期望的。目前为止,我唯一需要手动操作的就是每隔几个月登录一次 Redis Labs,确认我的账户仍然有效。
我对目前多人游戏系统的成果相当满意。我希望能够扩展《Farmhand》的多人游戏功能,并进一步完善其在线市场机制。我也很想听听大家的意见,因为我之前从未设计过如此完整的系统,所以很想了解如何才能做得更好。欢迎在下方评论区留言!如果你想体验轻松愉快的农场生活,不妨试试《Farmhand》吧。🙂
文章来源:https://dev.to/jeremyckahn/how-i-Design-an-abuse-pressive-fault-operative-zero-cost-multiplayer-online-game-140g

