第 13 课:构建一款链上太空射击游戏
踏上构建生产级应用的旅程。
一艘飞船。陨石从屏幕顶部掉落。一个激光按钮。分数往上涨,飞船爆炸,游戏结束。标准的街机玩法。不同之处在于:你的最高分活在 Monad 上,而不是我们的服务器上。
游戏是少数几个「链性能」会真正体现在用户体验里的场景之一。在更慢的链上,你几乎不可能把游戏的任何部分放上链而不毁掉手感,这也是为什么这个领域至今仍然年轻。Monad 的亚秒级最终性让这件事变得可行。在这节毕业课里,我们会从最简单的链上层入手——leaderboard(排行榜),因为它是讲清楚链上游戏中最重要那个理念最干净的方式:搞明白游戏的哪些部分应该上链、哪些不应该。游戏循环(game loop)本身要在你的浏览器里以 60fps 运行,那是它该待的地方。「谁是最强者」的公开记录则活在 Monad 上,没人能改它。这一课里其余所有东西都从这条切分线推导出来。
毕业课规则。 一次只发一个 prompt。如果你的智能体偏离了规格说明,就停下,把 prompt 重新粘一遍,写得更窄。所有积木(认证、签名、钱包、合约)都来自之前的课程。哪一块还有点模糊,就翻回去看。
我们要构建什么
游戏部分:
- 玩家用方向键控制的飞船。
- 陨石从屏幕顶部以越来越快的速度掉下来。
- 空格键发射激光,击毁陨石。
- 击毁陨石得分。
- 飞船被撞中,本局结束。
链上部分:
- 一个部署在 Monad testnet 上的 leaderboard 智能合约,存储每个玩家的最高分。
- 一局结束时,客户端把分数发到一个小型签名 API。
- API 用一个保管在服务器端的密钥给分数签名,把签名返回给客户端。
- 客户端从玩家的钱包里把
{score, signature}提交到合约。 - 合约校验签名确实来自可信的签名者,然后更新该玩家的最高分。
初中(sophomore)课程里学过的每一项技能在这里都有落点:
- 钱包与链上支付(payments lesson):玩家从自己的钱包调用合约。
- 安全性(implementing security):服务器签分数这套模式之所以存在,是因为你不能信任客户端。
- 性能(optimizing performance):游戏循环必须跑到 60fps,所以它留在链下。
- EVM 的工作方式(EVM lesson):合约用
ecrecover来校验签名。
为什么游戏循环本身不上链?
为什么要先让服务器给分数签名,客户端再把它提交上链?
搭建项目
打开你的 Repl,在 AI 面板里粘贴:
I'm building an on-chain space shooter. Scaffold a Next.js
project with:
- A single page / route. No auth, no database.
- Wallet connection via RainbowKit + wagmi + viem:
* Install @rainbow-me/rainbowkit, wagmi, viem,
@tanstack/react-query.
* Define a monadTestnet chain object (id 10143, name
"Monad Testnet", RPC https://testnet-rpc.monad.xyz,
native currency MON, 18 decimals).
* Create app/providers.tsx with 'use client' that exports
a <Providers> component wrapping children in:
<WagmiProvider config={config}>
<QueryClientProvider client={new QueryClient()}>
<RainbowKitProvider>
where config is built via getDefaultConfig({
appName: 'Space Shooter',
projectId: 'demo',
chains: [monadTestnet],
}).
* In app/layout.tsx, import '@rainbow-me/rainbowkit/styles.css'
and wrap {children} with <Providers>.
* Render <ConnectButton /> from @rainbow-me/rainbowkit fixed
in the top-right of the page (position: fixed; top: 16px;
right: 16px). Do NOT roll your own connect button. Any
page or component using wagmi hooks needs 'use client' at
the top.
- Environment variables read directly from process.env:
* NEXT_PUBLIC_CONTRACT_ADDRESS (empty for now)
* SIGNER_PRIVATE_KEY (empty for now, server-only)
- A <main> area that will hold the game canvas. Nothing in it
yet.
- Minimal UI. Black background, white text, monospace font.
This is a space game.
Before you say you're done, open the browser and verify that
clicking the Connect button opens the RainbowKit modal. If it
doesn't, the provider wrapping or 'use client' is wrong, fix
it before handing back to me.
脚手架跑起来、并且你能连接钱包之后,再继续往下走。
构建游戏循环
这是纯粹的浏览器部分。不涉及链,也不涉及后端。
> Build the space shooter inside an HTML canvas that fills the
<main> area.
Mechanics:
- Player ship at the bottom, moves left/right with arrow keys.
- Space bar fires a laser that travels straight up.
- Asteroids spawn at random x positions at the top, fall at
increasing speed over time.
- Laser hitting an asteroid destroys both and adds 10 points.
- Asteroid hitting the ship ends the game.
- Render current score in the top-left.
State machine:
- "idle": shows "Press space to start"
- "playing": game loop is running
- "over": shows final score + a "Submit to leaderboard" button
(disabled until a wallet is connected)
Keep the canvas at 800x600. Pure canvas 2D, no external game
libraries. Use requestAnimationFrame.
继续往下走之前,先把游戏完整玩一遍。如果游戏本身玩起来不好玩,后面的一切都没意义。
为什么用 requestAnimationFrame
游戏循环就是一个函数:更新状态(飞船位置、陨石、碰撞),重绘画布(canvas),然后安排自己再跑一次。这个循环怎么安排自己再跑,才是关键。
有两个选项。setInterval(loop, 16) 每 16ms 触发一次(大约每秒 60 次)。requestAnimationFrame(loop) 则是请求浏览器在下一次绘制前再调用你的函数。
用 requestAnimationFrame。它能让你的更新和浏览器的渲染周期同步,标签页被隐藏时会自动暂停(节省 CPU 和电量),并且会自动匹配显示器的刷新率(60Hz、120Hz),不需要改一行代码。setInterval 这些事一件都做不到。
模式是这样的:
function loop() {
update()
draw()
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
每一个浏览器游戏都是这么写的。
为什么游戏循环要用 requestAnimationFrame,而不是 setInterval?
部署 leaderboard 合约
这是最小可用的链上部分。
> Write a Solidity contract called Leaderboard.sol with this
behavior:
- Owner is set to the deployer. Owner can change the authorized
signer address.
- Public mapping bestScore(address player) -> uint256.
- Function submitScore(uint256 score, bytes signature):
* The message signed is keccak256(abi.encodePacked(msg.sender, score)).
* Use ECDSA (OpenZeppelin is fine) to recover the signer.
* Require the recovered address == authorizedSigner.
* If score > bestScore[msg.sender], update it and emit
NewBestScore(player, score).
- A view function topN(uint256 n) is NOT required on-chain;
we'll compute it off-chain from NewBestScore events.
Then deploy it to Monad testnet. Use Hardhat or Foundry, your
pick. Print out the deployed address and save it. Set the
authorizedSigner at deploy time to the address that derives from
SIGNER_PRIVATE_KEY.
把部署得到的地址粘贴到 NEXT_PUBLIC_CONTRACT_ADDRESS。生成一对全新的签名密钥(智能体可以用 viem 的 privateKeyToAccount 来做),把私钥粘贴到 SIGNER_PRIVATE_KEY。在继续之前确认这两个变量都已经在 shell 里设好。
安全。这个签名密钥是服务器端的秘密。
SIGNER_PRIVATE_KEY绝不能进入会被智能体提交的.env文件,也不能进入客户端代码。它只能存在于服务器环境里。如果它泄露了,任何人都可以为任何玩家伪造任意分数,整个 leaderboard 就毫无价值了。
在服务器上签分数
模式是这样的:客户端把自己的分数告诉服务器,服务器判断这个分数是否合理,给它签名,再把签名交回去。
> Add a POST /api/sign-score Next.js route that:
- Accepts { player: address, score: uint256 } in the body.
- Validates:
* score is a positive integer below a plausibility cap
(for now, 100000; a real game would fingerprint the run).
* player is a valid 0x address.
- Signs keccak256(abi.encodePacked(player, score)) with
SIGNER_PRIVATE_KEY using viem's signMessage for raw hashes.
- Returns { signature }.
Never expose SIGNER_PRIVATE_KEY to the client. The key is read
from process.env on the server only.
安全。是合理性,不是信任。 一个无脑的签名者会给任何分数签名,那整个模式就被废掉了。在真实的游戏里,你会对一局游戏做指纹(fingerprint):每秒输入次数、熵、replay token 等。在这节课里我们只是简单地给分数加了个上限(cap),但你要明白:只要分数低于这个上限,它就仍然可以被伪造,你真正的防线是那个还没写的 run fingerprint。
「相信任何低于上限的分数」这个策略的薄弱之处是什么?
把分数提交上链
现在把游戏里的「Submit to leaderboard」按钮接到合约上。
> When the "over" state renders and the user clicks "Submit to
leaderboard":
- POST { player: userAddress, score } to /api/sign-score.
- Take the returned signature and call
contract.submitScore(score, signature) from the user's wallet.
- While pending, show "Submitting..." on the button.
- On confirmation, replace the button with "Submitted!" and
refresh the leaderboard below.
- On any error, show a clear message and keep the button
enabled for retry.
显示 Top 10
因为合约会发出 NewBestScore 事件,你可以通过查询事件,在链下构建 leaderboard。
> Below the game canvas, render a "Top 10" leaderboard.
- Use viem's getLogs with the Leaderboard contract and the
NewBestScore event, fromBlock 0n.
- For each player, keep only the highest score they've ever
posted.
- Sort descending, take the top 10.
- Render as a numbered list: "1. 0xabc…def 420".
- Re-fetch the list on successful submission.
- Cache the list for 5 seconds so the UI doesn't hammer the
RPC.
为什么从事件里在链下算出 top 10,而不是在合约里加一个 topN 函数?
端到端跑一遍
- 连接你的钱包,玩一局,挂掉,点 Submit to leaderboard。你的钱包会弹出签名请求。签名,等确认,看到自己的分数出现在列表里。
- 换一个浏览器(或者无痕窗口),用另一个钱包重复一遍。玩一局,提交,确认列表会更新而且排序正确。
- 试着作弊:在 devtools 控制台里直接向
/api/sign-scorePOST{ player: yourAddress, score: 99999 }。在当前的合理性规则下(只是一个 cap),服务器会照样给它签名。这件事能成功,就是这节课的重点。
安全要点回顾
- 签名密钥 只存在于服务器端。绝不进入被提交的
.env,也绝不打包进客户端 JS。 - 服务器端的合理性检查 才是真正的防线。我们写的这个 cap 只是底线;真实的游戏会对输入做 fingerprint。
- 合约校验签名者 用的是
ecrecover,校验的是签名身份,而不是分数内容本身。一旦签名密钥泄露,整个 leaderboard 就完蛋。 - 限速(rate-limit) 那个 sign-score 路由,把密钥泄露的窗口期影响压到最小。
- 监控
NewBestScore事件里的异常情况(恰好低于 cap 的分数、来自同一地址的高频提交)。
你刚刚构建了什么
一款真正的链上游戏:
- 游戏循环以 60fps 在浏览器里运行,那是它该待的地方。
- 「谁是最强者」的记录活在 Monad 上,没有任何公司能修改它。
- 分数受一个服务器端密钥把关,玩家没法把自己刷到榜首。
- leaderboard 是从事件构建出来的——这是在链上读出有序列表最便宜的方式。
这是几乎所有「值得做的链上游戏」的模板。互动的部分留在客户端。玩家长期真正在意的东西(所有权、排名、奖励)活在链上。
0/5 正确
0% — 全部答对即可完成