做一个 IM 小游戏
聊天里玩游戏比想象中容易:Agent 持有游戏状态 + 客户端按 payload.ui
渲染交互卡。本教程做两个:单人猜数字(入门)+ 多人井字棋(进阶)。
详细 2048 完整实战见 状态化交互 — 2048 小游戏。
例 1:单人猜数字(最简)
用户故事
“用户对 Bot 说
/play guess,Bot 想一个 1-100 的数,用户猜,Bot 提示 大了 / 小了,猜对就赢。轻量、随时玩 30 秒。“
完整代码
import { HasheeAgent } from "@hasheeai/agent-sdk-ts";import { ulid } from "ulid";
const agent = await HasheeAgent.init({ agentId: process.env.HASHEE_AGENT_ID!, token: process.env.HASHEE_AGENT_TOKEN!, baseUrl: "https://api.hashee.ai", connectionMode: "websocket",});
type GameState = { artifactId: string; conversationId: string; target: number; triesUsed: number; maxTries: number; history: { guess: number; hint: "high" | "low" | "correct" }[]; won: boolean; revision: number;};
const games = new Map<string, GameState>(); // key = `${conv}:${user}`
agent.addMessageHandler(async (msg) => { if (msg.payload?.type !== "text") return; const text = msg.payload.text.trim(); if (text === "/play guess") return startGuess(msg);});
agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return; if (event.payload.action !== "guess") return; const game = findGameByArtifactId(event.payload.ref_artifact); if (!game || game.won) return;
const guess: number = Number(event.payload.payload?.guess); if (!Number.isInteger(guess) || guess < 1 || guess > 100) return; game.triesUsed++; if (guess === game.target) { game.history.push({ guess, hint: "correct" }); game.won = true; } else { game.history.push({ guess, hint: guess > game.target ? "high" : "low" }); } game.revision++; await agent.updateArtifact(game.conversationId, { ref_artifact: game.artifactId, based_on_revision: game.revision - 1, artifact: { payload: render(game) }, });});
function startGuess(msg: any) { const game: GameState = { artifactId: ulid(), conversationId: msg.conversation_id, target: 1 + Math.floor(Math.random() * 100), triesUsed: 0, maxTries: 10, history: [], won: false, revision: 0, }; games.set(`${msg.conversation_id}:${msg.sender_id}`, game); return agent.sendArtifact(msg.conversation_id, { artifact: { artifact_id: game.artifactId, subtype: "app", title: "猜数字 (1-100)", payload: render(game), capability_flags: { allows_response: true }, }, });}
function render(g: GameState) { return { ui: "guess_number", min: 1, max: 100, tries_used: g.triesUsed, max_tries: g.maxTries, history: g.history, won: g.won, game_over: g.triesUsed >= g.maxTries, };}
function findGameByArtifactId(artifactId: string): GameState | undefined { for (const g of games.values()) if (g.artifactId === artifactId) return g;}
agent.addStatusHandler((s) => console.log(`[status] ${s}`));console.log("[guess-bot] up");例 2:多人井字棋(进阶)
用户故事
“群里
@Bot ttt @user_a @user_b开局;轮到的人在卡片上点格子;轮到 你时 @mention 你提醒;连成线即胜。“
完整代码
type TttState = { artifactId: string; conversationId: string; players: [string, string]; // [X, O] board: ("X" | "O" | null)[]; // 9 格 turn: 0 | 1; winner: "X" | "O" | "draw" | null; revision: number;};
const tttGames = new Map<string, TttState>();
agent.addMessageHandler(async (msg) => { // 同上 messageHandler 内增加: const m = msg.payload?.text?.match(/^@\S+\s+ttt\s+@<U:([\w-]+)>\s+@<U:([\w-]+)>/); if (m) { const xPlayer = m[1], oPlayer = m[2]; const game: TttState = { artifactId: ulid(), conversationId: msg.conversation_id, players: [xPlayer, oPlayer], board: Array(9).fill(null), turn: 0, winner: null, revision: 0, }; tttGames.set(game.artifactId, game); await agent.sendArtifact(msg.conversation_id, { artifact: { artifact_id: game.artifactId, subtype: "app", title: "井字棋", payload: renderTtt(game), capability_flags: { allows_response: true }, }, }); // 提示第一人下 await agent.send(msg.conversation_id, { type: "text", text: `@<U:${xPlayer}> 你先走 (X)`, mentions: [xPlayer], }); }});
agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return; if (event.payload.action !== "place") return; const g = tttGames.get(event.payload.ref_artifact); if (!g || g.winner) return;
const currentPlayer = g.players[g.turn]; if (event.sender_id !== currentPlayer) { // 不是轮到的人,忽略 (客户端 UI 应该 disable,但服务端也校验) return; }
const idx: number = event.payload.payload?.cell; if (!Number.isInteger(idx) || idx < 0 || idx > 8 || g.board[idx] !== null) return;
g.board[idx] = g.turn === 0 ? "X" : "O"; g.winner = checkWinner(g.board); if (!g.winner) g.turn = g.turn === 0 ? 1 : 0;
g.revision++; await agent.updateArtifact(g.conversationId, { ref_artifact: g.artifactId, based_on_revision: g.revision - 1, artifact: { payload: renderTtt(g) }, });
if (g.winner) { const winText = g.winner === "draw" ? "平局!" : `🎉 ${g.players[g.winner === "X" ? 0 : 1]} 赢了!`; await agent.send(g.conversationId, { type: "text", text: winText }); } else { // 提示下一人 const next = g.players[g.turn]; await agent.send(g.conversationId, { type: "text", text: `@<U:${next}> 你下 (${g.turn === 0 ? "X" : "O"})`, mentions: [next], }); }});
function renderTtt(g: TttState) { return { ui: "tic_tac_toe", board: g.board, players: g.players, turn: g.turn, winner: g.winner, };}
function checkWinner(b: (string | null)[]): "X" | "O" | "draw" | null { const lines = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]]; for (const [a, b1, c] of lines) { if (b[a] && b[a] === b[b1] && b[a] === b[c]) return b[a] as "X" | "O"; } return b.every((cell) => cell !== null) ? "draw" : null;}排行榜(持久化)
import { Pool } from "pg";const db = new Pool({ connectionString: process.env.DATABASE_URL });
// 游戏结束时调async function recordWin(userId: string, game: "guess" | "ttt") { await db.query(` INSERT INTO leaderboard (user_id, game, wins) VALUES ($1, $2, 1) ON CONFLICT (user_id, game) DO UPDATE SET wins = leaderboard.wins + 1 `, [userId, game]);}
async function showLeaderboard(convId: string, game: string) { const r = await db.query( `SELECT user_id, wins FROM leaderboard WHERE game = $1 ORDER BY wins DESC LIMIT 10`, [game], ); await agent.sendArtifact(convId, { artifact: { subtype: "info", title: `${game} 排行榜 (Top 10)`, payload: { body: r.rows.map((row, i) => `${i + 1}. @<U:${row.user_id}> — ${row.wins} 胜`).join("\n"), }, }, });}设计建议
- 单人游戏:状态可放 in-memory(重启丢;用户重开即可)
- 多人 / 持久游戏:必须 DB / Redis(避免 Agent 进程崩溃丢局)
- 客户端 renderer 不存在时(不认识
payload.ui)→ fallback 显示原始 JSON, 确保核心信息(轮次 / 棋盘)也在文字里能看到 - 每条 update 走 E2EE 管线;高频游戏(每秒 5+ update)注意 SDK 限速
相关页面
- 状态化交互 — 2048 小游戏 — 完整 2048 实战
- Artifact 高级 — 双向交互
- 设计准则 — UX best practice