☰
从零自研放置卡牌服务端:数据模型、通信协议与反作弊实战
2026/10/1 13:44:47 网站建设 项目流程

前阵子有个做后端的朋友半夜给我发消息,说他刷到一条帖子,标题写着"咸鱼之王私服搭建 + 附源码下载链接",底下还挂着网盘链接,问我有没有兴趣一起搞一个,说流量肯定好。我回了他四个字:别碰这个。不是我不爱折腾,而是这条路从第一天起就站在一个很尴尬的位置上——那套服务端是别人打包好的二进制,你既不知道里面塞了什么,也不知道哪天它会反手把你自己的数据端走;更现实的是,拿别人的美术、数值、玩法去做一套对外运营的东西,性质上根本说不清楚。所以后来我们换了个思路:既然喜欢的是那套放置卡牌的玩法结构,那就自己写一套原创的放置卡牌服务端。下面这篇就是我这两个多月从零搭起来的过程,包括数据模型、通信协议、反作弊、部署压测,尽量把每个"为什么这么设计"讲清楚,适合有点编程基础、想自己动手做一套小规模游戏服务端的人参考。

1. 先想清楚"自研"和"拿来主义"的分界线在哪

很多人搜"私服搭建""源码下载"的时候,脑子里想的是"省事"——弄一份现成的包,改改数值,租台机器就能跑。我完全理解这种心态,但从工程角度看,这笔账其实算不过来,而且问题的核心不在技术难度,在于你根本没法掌控那套东西。

1.1 拿来主义省下的时间,后面会加倍还回去

一份来路不明的服务端包,最典型的特征是:没有文档、没有构建脚本、没有数据库迁移记录。你拿到的是一个能跑的目录,里面可能有.exe、可能有加密过的.dll、可能有一堆没有注释的脚本文件。这种项目一旦出问题,排查成本是灾难级的。

我见过最典型的情况是数据库表结构改了但没人知道怎么改回去,最后只能删档重开。放置类游戏最值钱的就是玩家存档和累计收益,删一次档,玩家的信任就没了。相比之下,自己写的代码哪怕粗糙,至少每一行你都知道它为什么在那里。

还有一层是安全。别人打包的程序里带一个定时上报或者后门接口,从外部看是完全无感的,日志里看不出任何异常。你的服务器配置、你的数据库账号密码、你的支付回调密钥,全都在它手边。这不是危言耸听,而是这类"免费包"最常见的商业模式——前期免费给你用,等你有量了再谈"授权"。

1.2 玩法结构可以学,素材和代码不能抄

这条线必须划清楚。放置卡牌的核心循环——挂机产出资源、资源换养成、养成分阶段解锁新关卡——这是品类层面的通用设计思路,属于玩法框架,学它没有问题。但具体到:

  • 每一个武将/角色的立绘、名字、技能文案
  • 具体的数值曲线和关卡配置
  • 客户端和服务端的实际代码

这三样都属于别人的创作成果,不能直接搬。所以我的做法是:框架参考品类共性,内容全部原创。数值曲线自己推,美术用可商用授权的素材或者干脆先用色块占位,技能文案自己写。这样即使后面想商业化,路也是通的。

1.3 一个人做,MVP 到底能砍到什么程度

"自己写一套游戏服务端"听起来很大,但砍完之后其实没那么夸张。我的第一版 MVP 只做了四件事:

  1. 玩家注册登录,拿到一个初始存档
  2. 挂机产出每分钟结算一次,离线后按时间戳补发
  3. 关卡战斗走服务端结算,返回战报
  4. 养成操作(升级、升星)改存档并回写数据库

就这四件事,跑通之后游戏的骨架就立起来了。后面加排行榜、加活动、加内购,都是在骨架上挂东西。所以第一章我想强调的是:难度不在总量,在于顺序。先做能闭环的最小版本,再谈扩展,这比一上来就设计一套"完美架构"务实得多。

2. 数据模型:把"配置"和"存档"彻底分开

这是我踩的第一个坑,也是我后来觉得收益最大的一个设计决定。一开始我把武将的攻击力、血量这些数值直接写在代码里,改一次数值就要重新部署一次,测试同学天天来找我。后来改成配置表驱动,整个开发节奏完全变了。

2.1 配置表与代码分离,是数值迭代的前提

核心思路很简单:代码只负责"怎么算",配置负责"算多少"。所有能调的数值——角色属性、关卡掉落、升级消耗、收益速率——全部抽到独立的配置文件里。

我用的是一张 CSV 表配合一个加载脚本,启动时读进内存,用不可变字典存着。格式大概是这样:

{ "hero_id": 1001, "name": "青石卫", "base_atk": 120, "base_hp": 1800, "atk_growth": 12.5, "hp_growth": 210, "skill_id": 20001, "rarity": 3 }

这里有个细节值得一提:atk_growth我用的是小数,而不是整数。原因是我后来发现,如果成长值只能取整数,后期等级拉高之后不同角色之间的强度差会变得非常粗糙,调平衡的时候手感很差。用浮点存成长值、最终结算时再取整,调起来细腻多了。

配置加载的时候一定要做校验,这是很多人会漏的一步。我写了一个检查函数,专门盯这几件事:

校验项检查内容出问题的后果
唯一性hero_id 是否重复后加载的覆盖先加载的,数值莫名其妙
引用完整性skill_id 是否存在于技能表战斗时找不到技能,直接崩
范围数值是否为正数、是否超过上限溢出甚至负数,玩家白嫖属性
枚举rarity 是否在允许集合内稀有度筛选、排序逻辑出异常

这张表我建议每个做配置驱动的项目都对照检查一遍,因为配置错误往往是那种"上线三天后才被玩家发现"的隐蔽问题。

2.2 战斗结算必须放在服务端,而且要固定随机种子

放置类游戏的战斗通常是自动推演,几十秒内打完一场,客户端只负责播放。这里有个很关键的点:战斗过程必须在服务端跑,客户端拿到的只是一份战报。如果让客户端算伤害,玩家改个内存就能一刀秒 Boss,你所有的数值设计全部作废。

服务端结算的伪代码大致是:

def settle_battle(player, stage_id, seed): rng = Random(seed) # 种子由服务端生成,客户端无法预测 heroes = build_team(player) enemies = load_stage_enemies(stage_id) log = [] tick = 0 while tick < MAX_TICK: for unit in alive_units(heroes + enemies): target = pick_target(unit, rng) damage = calc_damage(unit, target, rng) target.hp -= damage log.append({"t": tick, "src": unit.id, "dst": target.id, "dmg": damage}) tick += 1 if battle_over(heroes, enemies): break return {"win": all_dead(enemies), "log": log, "seed": seed}

注意pick_target和calc_damage都吃 rng,也就是说相同的种子必然产生相同的结果。这一点非常有用:玩家如果对结果有异议,你可以用同一个种子重新跑一遍,结果完全一致,客服成本直接降下来。同时它也方便你做回放功能,只需要存种子和初始阵容,几十 KB 的战报就变成几百字节。

提示:种子一定要用服务端的高熵随机源生成,不要用时间戳的秒级值。秒级时间戳可以被枚举,玩家只要试几十次就能刷到对自己有利的战报。

2.3 存档模型:我为什么最后选了分表而不是单文档

一开始我的存档就是一个大 JSON,整个塞进一个字段里。简单是真简单,但很快撞墙了:

  • 并发写:挂机结算、升级、领奖可能同时改同一个 JSON,后写的覆盖先写的,玩家会发现领的奖励消失了
  • 查询:想做全服排行榜,得把所有人的 JSON 都读出来解析一遍,几十万玩家直接卡死
  • 体积:背包物品越堆越多,单条记录涨到几百 KB,读写都变慢

后来改成"热点字段分表 + 冷数据 JSON"的混合方案:

CREATE TABLE player_base ( uid BIGINT PRIMARY KEY, level INT NOT NULL DEFAULT 1, exp BIGINT NOT NULL DEFAULT 0, last_settle BIGINT NOT NULL DEFAULT 0, power INT NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE player_hero ( uid BIGINT NOT NULL, hero_id INT NOT NULL, star TINYINT NOT NULL DEFAULT 1, level INT NOT NULL DEFAULT 1, PRIMARY KEY (uid, hero_id) );

power(战力)这个字段是冗余出来的,专门给排行榜用,它不需要绝对精确,只需要排序正确。每次养成操作后异步更新一次,排行榜查询走覆盖索引,几十万行也是毫秒级。用一点点数据一致性,换查询性能,在排行榜这种场景里非常划算。

2.4 离线收益:时间戳要签名,否则就是提款机

离线收益是放置游戏的灵魂,也是最容易被薅的地方。基础逻辑是:玩家下线时间t1,上线时间t2,按(t2 - t1) * rate补发收益。问题在于t1如果存在客户端,玩家把手机时间往回调,就能反复领。

我的做法是:

  1. 服务端在每次结算后把last_settle写进数据库,并下发一个带 HMAC 签名的令牌给客户端
  2. 客户端下次请求带上令牌,服务端先验签,再比对last_settle
  3. 收益上限用服务端时间卡死,超过 12 小时的部分按 12 小时算
def calc_offline_gain(player, token): if not verify_hmac(token, SECRET): raise CheatDetected("bad token") now = server_now() elapsed = min(now - player.last_settle, MAX_OFFLINE_SECONDS) if elapsed < 0: raise CheatDetected("clock rollback") gain = int(elapsed * player.rate) player.last_settle = now return gain

server_now()必须取服务端时间,绝对不能信客户端传上来的时间。这几行代码看着不起眼,但它挡住的是最常见的作弊方式,我实测下来,加了校验之后异常收益请求量直接掉了九成以上。

3. 通信协议:HTTP 和 WebSocket 各管一摊

协议选型上我纠结过一阵子要不要全用长连接,最后选了双通道方案。做完之后我觉得这是这个项目里最值得分享的一个决定。

3.1 什么时候用 HTTP,什么时候用长连接

判断标准其实就一条:这次交互需不需要服务端主动推。

场景通道原因
登录、拉配置HTTP一次性,可缓存,走 CDN 都行
升级、升星、领奖HTTP请求-响应模型天然契合,失败直接重试
推图战斗HTTP有明确结果,一次返回战报
排行榜刷新、活动开启、聊天WebSocket需要服务端主动推
挂机收益结算提醒WebSocket定时推送,不需要客户端轮询

全部塞进长连接的问题是:长连接天然要处理断线、重连、心跳、消息乱序,而这些复杂度只在少数场景里才需要。HTTP 那部分请求天然是幂等的、可重试的,写起来省心太多。

3.2 协议结构:消息号 + 请求 ID + 时间戳

我的协议格式很朴素,JSON 为主(内部接口用 Protobuf 压了一下流量):

{ "cmd": 1003, "rid": "a7f3e1c9-8b2d-4f01", "ts": 1735689600, "body": { "stage_id": 42, "hero_ids": [1001, 1002, 1003] } }

其中rid是客户端生成的唯一请求 ID,这个字段是整个协议里最重要的一个,下面细说。cmd用整数编号而不是字符串,是为了省流量和方便做路由表;ts用于服务端做时间窗口校验,偏差超过 300 秒的请求直接拒掉。

3.3 断线重连和消息幂等,靠 rid 兜底

移动端网络环境很糟糕,地铁里切个隧道就断一次。如果玩家点"升级",请求发出去了但响应没回来,他大概率会再点一次,结果就是扣了两次资源。

我的处理方式是用 rid 做幂等键:

async function handle(client, msg) { const key = `idem:${client.uid}:${msg.rid}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); // 直接返回上次的结果 const result = await dispatch(msg.cmd, client, msg.body); await redis.set(key, JSON.stringify(result), 'EX', 300); return result; }

Redis 里存 5 分钟就够了,正常人不会隔五分钟才重试。这样即使网络抖动导致重发,玩家也只会收到同一份结果,资源不会重复扣。这个改法上线之后,"资源凭空少了"这类客诉基本消失了。

重连部分我额外做了两件事:一是连接建立后把玩家在线的这段时间内产生的推送消息补发一次(存一个最近 50 条的环形缓冲);二是重连成功后客户端主动拉一次全量关键状态,避免本地缓存和服务端不一致。

3.4 为什么我没做全 Protobuf

有人会问,既然对流量敏感,为什么不全部上 Protobuf。我的考虑是:开发期的可调试性比流量更重要。JSON 抓包一眼就能看懂,调试接口的时候节省的时间远超那点流量成本。我的做法是分层——对外的主接口用 JSON,内部的战斗日志、排行榜批量数据用 Protobuf 压。等游戏真的到需要抠流量的规模,再整体替换也不迟,协议层抽象好了,换起来就是改一个编解码器。

4. 反作弊:放置游戏最容易被薅的四个口子

放置类游戏的特点是"数值增长快、链条长",所以作弊的收益点特别多。这一章我把实际遇到过的四类问题整理出来,每一条都附上我最终的处置方式。

4.1 服务端权威原则,一条都不能破

这条是底线:任何影响玩家资产或进度的计算,必须在服务端完成。包括伤害结算、掉落判定、收益计算、抽卡随机。客户端能做的只有展示和输入。

我在代码 review 的时候会专门搜一下有没有if (client_result.win)这种写法——只要出现"信任客户端传上来的结果",基本就是漏洞。正确写法永远是客户端传意图(我要打第 42 关),服务端返回结果(你赢了,掉落这些)。

4.2 时间与随机数,是最容易被打的两个点

时间相关的问题前面讲离线收益提过了,这里补充一个更隐蔽的:关卡限时次数。如果每日挑战次数用本地日期重置,玩家改时区就能天天刷新。我的做法是统一用服务端 UTC+8 的自然日做 key,跨天判断也在服务端做。

随机数方面,除了战斗种子,抽卡也是重灾区。一定不要用Math.random()这种客户端可预测的源,服务端这边我用的是带种子的 CSPRNG,并且每次抽卡都记录一条审计日志:谁、什么时候、消耗了什么、抽到了什么。日志落盘之后,出现"同一个人连抽十个顶级角色"的情况,你至少能查。

4.3 数值溢出和负数,是批量刷道具的入口

这类问题的典型形态是:某个操作的参数是玩家可控的,比如"使用 N 个经验丹",服务端没做上下界校验,玩家传个负数-99999,结果背包里反而多出来一堆。

我的防御是三层的:

  • 入参层:所有数值参数做范围校验,数量类参数必须1 <= n <= 背包持有量
  • 计算层:所有资源变更用整数,加减后立刻检查>= 0,越界直接抛异常并回滚事务
  • 审计层:单次资源变更超过阈值的操作,单独打一条 warning 日志
def consume(player, item_id, count): assert 1 <= count <= 9999, "invalid count" owned = player.bag.get(item_id, 0) if owned < count: raise NotEnough() player.bag[item_id] = owned - count assert player.bag[item_id] >= 0 if count > 1000: logger.warning("large consume uid=%s item=%s n=%s", player.uid, item_id, count)

注意:整数溢出在不同语言里行为不一样。Python 会自动转大整数,Go 的 int64 会回绕。如果你的核心逻辑用 Go 写,一定要显式检查边界,别指望它报错。

4.4 内购回调和发卡类逻辑,验签和幂等一个都不能少

如果游戏里接了内购、月卡、礼包码这类东西,回调接口的安全等级要拉到最高。我总结的三个必做项:

  1. 验签:用 HMAC-SHA256 对回调原始报文验签,密钥只存在服务端环境变量里,不落代码仓库
  2. 幂等:以订单号为唯一键,处理过的订单直接返回成功,绝不重复发货
  3. 金额校对:回调里的金额、商品 ID 必须和本地订单记录一致,不一致的订单直接挂起人工审核
def on_pay_callback(raw_body, signature, headers): if not hmac_compare(signature, sign(raw_body, PAY_SECRET)): return {"code": 401} order = parse(raw_body) if redis.set(f"pay:{order.order_id}", 1, nx=True, ex=86400) is None: return {"code": 0} # 已处理过,直接确认 local = db.get_order(order.order_id) if local is None or local.amount != order.amount: alert(order) # 金额不符,人工介入 return {"code": 402} grant_items(local.uid, local.goods_id) return {"code": 0}

这里set nx=True是关键,它把"判断是否处理过"和"标记为已处理"合并成一个原子操作,避免了并发回调下的重复发货。这个模式在很多业务里都通用,值得记住。

5. 从本机到一台小服务器:部署、压测和上线检查

代码能跑和能给人玩,中间隔着一段距离。这一章讲我怎么把它从localhost挪到一台普通云主机上,并且扛住第一批玩家。

5.1 选型:我最后用的组合和理由

层选择理由
接入Nginx反代 + WebSocket 升级 + 静态资源,够用且稳
业务Python (FastAPI)开发快,异步 IO 处理长连接比较顺手
缓存Redis幂等键、排行榜 ZSet、在线状态,三合一
存储MySQL事务保证,存档一致性比纯 KV 好管
部署Docker Compose单机多容器,配置即代码,迁移方便

选 Python 的原因很实在:这个项目的瓶颈在数据库和网络,不在 CPU 计算。用 Python 写得快,遇到问题排查也快。真到了性能瓶颈,先加 Redis 缓存、再拆服务,比一开始就上 Go 更划算。

5.2 容器编排和 WebSocket 转发,两个容易踩的配置

docker-compose.yml里我做了三件事:给业务容器限制内存(防止内存泄漏拖垮整机)、把日志输出到 stdout 交给宿主机统一收集、数据库和 Redis 只开内网端口。

Nginx 这边最关键的是长连接的转发配置:

location /ws { proxy_pass http://game:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s; } location /api { proxy_pass http://game:8000; proxy_read_timeout 30s; }

proxy_read_timeout我给到了 300 秒,配合客户端 60 秒一次的心跳,连接基本不会被中途掐断。这两个超时如果不设,默认 60 秒就会断,你会看到日志里大量"莫名其妙的重连",排查半天才发现是网关干的。

5.3 压测:先搞清楚瓶颈在哪一层

我用 k6 写了脚本,模拟三件事:登录拉配置、推图战斗、挂机结算。指标只盯四个:

  • P99 延迟:平均值骗人,尾延迟决定玩家体感
  • 错误率:超过 0.5% 就要停下来看
  • 数据库连接数:这个最容易成为第一瓶颈
  • Redis 命中率:命中率掉下来,说明缓存策略有问题

第一次压测结果很难看,200 并发的时候 P99 就冲到 800ms。查下来是两个问题:一是每次战斗都要读一遍配置表,二是排行榜每次请求都全量查 MySQL。修完这两处(配置表加进程内缓存 + 排行榜搬 Redis ZSet),同样的并发下 P99 降到 60ms 左右。

5.4 上线前我会过一遍的检查清单

检查项为什么重要
数据库账号是否为最小权限业务账号不该有 DROP 权限
密钥是否都在环境变量里代码仓库一旦泄露,密钥跟着走
是否有关键字段的备份策略存档丢了就是灭顶之灾
日志是否带 uid 和 rid出了问题能精准定位到玩家和请求
是否有资源变更审计日志事后追责和数据回滚的依据
限流是否生效单 IP 高频请求要有兜底

这张表我建议直接抄走,每次上线前照着打勾。里面任何一条出问题,带来的都不是"体验差一点",而是"玩家资产受损"。

这两个多月下来,我最大的感受是:自研这条路前期确实比拿现成的包慢,但它的收益曲线是完全不同的。前两周你觉得处处都在造轮子,第三周开始,改数值不用重新部署了,出问题能定位到具体请求了,玩家反馈的异常两小时就能修完。这种"掌控感"不是省钱换来的,是省心换来的。另外分享一个小技巧:把战斗结算函数单独抽成一个纯函数,不依赖任何数据库和网络,然后用固定的种子写一批单元测试。我后面每次调数值,跑一遍测试就知道有没有把战斗逻辑改坏,比手动点十遍强太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询