跳转到内容

做一个 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 限速

相关页面