Turns Claude Code sessions into a little coding adventure: XP, levels, quests and achievements.

Turn your Claude Code sessions into a coding RPG: XP, levels, quests and achievements.
Before installing: read the source code and make sure you trust it. Code Quest runs inside Claude Code with the same permissions Claude Code has, and it is written by its publisher, not Anthropic.
/plugin marketplace add VardhanKirti/claude-code-mods/plugin install code-quest@my-mods/reload-pluginsIf Code Quest doesn't appear, restart Claude Code.
/quest shows full status and achievements./quest set <objective>, /quest complete, /quest abandon manage the quest.XP: read a file +5, modify +10, passing test run +25, git commit +30, quest complete +250.
hooks/code-quest.mjs 178 lines1// Code Quest: XP, levels, quests and achievements for your coding session.
2
3const QUEST = { plugin: "code-quest", key: "quest" };
4const QUEST_REWARD = 250;
5const MAX_REWARDS = 4;
6
7const EMPTY = {
8 xp: 0,
9 objective: null,
10 reads: 0,
11 edits: 0,
12 testsPassed: 0,
13 commits: 0,
14 questsDone: 0,
15 rewards: [],
16 achievements: [],
17};
18
19const ACHIEVEMENTS = [
20 { id: "first-blood", name: "First Blood", hint: "Make your first edit", has: (q) => q.edits >= 1 },
21 { id: "test-enjoyer", name: "Test Enjoyer", hint: "Pass 25 test runs", has: (q) => q.testsPassed >= 25 },
22 { id: "committed", name: "Committed", hint: "Make 5 commits", has: (q) => q.commits >= 5 },
23 { id: "quest-giver", name: "Quest Complete", hint: "Complete a quest", has: (q) => q.questsDone >= 1 },
24 { id: "veteran", name: "Veteran", hint: "Reach level 5", has: (q) => levelOf(q.xp).level >= 5 },
25];
26
27const TEST_COMMAND = /\b(test|pytest|jest|vitest|phpunit|mocha|rspec)\b/i;
28const COMMIT_COMMAND = /\bgit\s+commit\b/i;
29
30// Level n needs n * 100 XP to finish, so level 1 ends at 100, level 2 at 300, ...
31function levelOf(xp) {
32 let level = 1;
33 let left = xp;
34 while (left >= level * 100) {
35 left -= level * 100;
36 level += 1;
37 }
38 return { level, into: left, size: level * 100 };
39}
40
41let queue = Promise.resolve();
42
43// Serialized read-modify-write, so parallel tool calls can't lose an update.
44function change($, fn) {
45 queue = queue.then(async () => {
46 const { value } = await $.state.get(QUEST);
47 const before = { ...EMPTY, ...value };
48 const after = fn({ ...before, rewards: [...before.rewards], achievements: [...before.achievements] });
49 if (levelOf(after.xp).level > levelOf(before.xp).level) {
50 $.ui.toast(`LEVEL UP! You reached level ${levelOf(after.xp).level}`);
51 }
52 for (const a of ACHIEVEMENTS) {
53 if (!after.achievements.includes(a.id) && a.has(after)) {
54 after.achievements.push(a.id);
55 $.ui.toast(`Achievement unlocked: ${a.name}`);
56 }
57 }
58 await $.state.set(QUEST, after);
59 });
60 return queue;
61}
62
63function reward(q, xp, label) {
64 return { ...q, xp: q.xp + xp, rewards: [...q.rewards, { xp, label }].slice(-MAX_REWARDS) };
65}
66
67export function register(on) {
68 on("session.start", async ($, e, next) => {
69 // Commands register inside a hook: `$` only exists there.
70 await $.command.register({ name: "quest", description: "Code Quest: status, set <objective>, complete, abandon" });
71 return next(e);
72 });
73
74 // Pick up the first prompt as the quest, unless one is already set.
75 on("prompt.submit", async ($, e, next) => {
76 const text = String(e.text ?? "").trim();
77 if (text && !text.startsWith("/")) {
78 await change($, (q) => (q.objective === null ? { ...q, objective: text.slice(0, 60) } : q));
79 }
80 return next(e);
81 });
82
83 on("tool.call", async ($, e, next) => {
84 const ran = await next(e);
85 if (ran.deny !== undefined) return ran;
86 const hasFailed = ran.isError === true;
87
88 if (e.tool === "Read" && !hasFailed) {
89 await change($, (q) => ({ ...reward(q, 5, `Read ${base(e.file_path)}`), reads: q.reads + 1 }));
90 } else if ((e.tool === "Edit" || e.tool === "Write" || e.tool === "MultiEdit") && !hasFailed) {
91 await change($, (q) => ({ ...reward(q, 10, `Modified ${base(e.file_path)}`), edits: q.edits + 1 }));
92 } else if (e.tool === "Bash" && !hasFailed) {
93 const command = String(e.command ?? "");
94 if (COMMIT_COMMAND.test(command)) {
95 await change($, (q) => ({ ...reward(q, 30, "Git commit"), commits: q.commits + 1 }));
96 } else if (TEST_COMMAND.test(command)) {
97 await change($, (q) => ({ ...reward(q, 25, "Tests passed"), testsPassed: q.testsPassed + 1 }));
98 }
99 }
100 return ran;
101 });
102
103 on("command.run", { command: "quest" }, async ($, e) => {
104 const args = String(e.args ?? "").trim();
105 const [sub, ...rest] = args.split(/\s+/);
106
107 if (sub === "set" && rest.length > 0) {
108 await change($, (q) => ({ ...q, objective: rest.join(" ").slice(0, 60) }));
109 } else if (sub === "complete") {
110 const { value } = await $.state.get(QUEST);
111 if (!value?.objective) return { text: "No active quest to complete. Try /quest set <objective>." };
112 await change($, (q) => ({ ...reward(q, QUEST_REWARD, "Quest complete"), objective: null, questsDone: q.questsDone + 1 }));
113 } else if (sub === "abandon") {
114 await change($, (q) => ({ ...q, objective: null }));
115 }
116
117 const { value } = await $.state.get(QUEST);
118 return { text: statusText({ ...EMPTY, ...value }) };
119 });
120
121 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
122 if (e.props.hasSurvey) return next(e);
123 const { value } = await $.state.get(QUEST);
124 const q = { ...EMPTY, ...value };
125 const { level, into, size } = levelOf(q.xp);
126 const { Box, Text } = $.ui.resolve(e);
127 const last = q.rewards[q.rewards.length - 1];
128
129 return Box({
130 flexDirection: "column",
131 paddingX: 1,
132 children: [
133 Box({
134 flexDirection: "row",
135 gap: 1,
136 children: [
137 Text({ bold: true, color: "yellow", children: `⚔ Lv${level}` }),
138 Text({ color: "yellow", children: bar(into, size, 16) }),
139 Text({ dimColor: true, children: `${into}/${size} XP` }),
140 last
141 ? Text({ color: "green", children: `+${last.xp} ${last.label}` })
142 : Text({ dimColor: true, children: "no rewards yet" }),
143 ],
144 }),
145 Text({ dimColor: true, children: q.objective ? `Quest: ${q.objective}` : "No active quest. /quest set <objective>" }),
146 ],
147 });
148 });
149}
150
151function statusText(q) {
152 const { level, into, size } = levelOf(q.xp);
153 const lines = [
154 "⚔ CODE QUEST",
155 "",
156 `Level ${level} XP ${into}/${size} ${bar(into, size, 20)}`,
157 `Quest: ${q.objective ?? "none (use /quest set <objective>)"}`,
158 "",
159 `Files read ${q.reads} Files modified ${q.edits} Tests passed ${q.testsPassed} Commits ${q.commits} Quests done ${q.questsDone}`,
160 "",
161 "Recent rewards",
162 ...(q.rewards.length ? q.rewards.slice().reverse().map((r) => ` +${r.xp} ${r.label}`) : [" none yet"]),
163 "",
164 "Achievements",
165 ...ACHIEVEMENTS.map((a) => ` [${q.achievements.includes(a.id) ? "x" : " "}] ${a.name} - ${a.hint}`),
166 ];
167 return lines.join("\n");
168}
169
170function bar(into, size, width) {
171 const filled = Math.max(0, Math.min(width, Math.round((into / size) * width)));
172 return "█".repeat(filled) + "░".repeat(width - filled);
173}
174
175function base(path) {
176 return String(path ?? "file").split(/[\\/]/).pop();
177}
178types/index.d.ts 20 lines1export type Reward = { xp: number; label: string };
2
3export type Quest = {
4 xp: number;
5 objective: string | null;
6 reads: number;
7 edits: number;
8 testsPassed: number;
9 commits: number;
10 questsDone: number;
11 rewards: Reward[];
12 achievements: string[];
13};
14
15declare module "claude-code" {
16 interface PluginState {
17 "code-quest": { quest: Quest };
18 }
19}
20