Playtest feedback
@gameforge/playtest-feedback
Turn messy playtest notes into a ranked list of what to fix next. Tags each note (bug, confusion, frustration, boredom, delight, idea), tallies them with a script, ranks issues by how many testers hit them and how badly, and lists the moments players loved so you protect them.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Tally playtest notes from a CSV into a ranked summary.
- 3// Usage: node scripts/tally-playtest.mjs data/playtest.csv out/playtest-summary.md [--json out/playtest-summary.json]
- 4//
- 5// CSV columns (header row required, any order, extra columns ignored):
- 6// tester who played (a nickname or "P1" is fine)
- 7// level where it happened (level name, "menu", "tutorial")
- 8// type bug | confusion | frustration | boredom | delight | idea
- 9// severity 1 minor, 2 hurts the fun, 3 blocks progress (ignored for delight and idea)
- 10// tag short shared label, for example "jump-too-floaty"; notes with the same tag are one issue
- 11// note what the tester said or did, in their words
- 12import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 13import { dirname } from "node:path";
- 15const TYPES = ["bug", "confusion", "frustration", "boredom", "delight", "idea"];
- 16const PROBLEM_TYPES = new Set(["bug", "confusion", "frustration", "boredom"]);
- 18function fail(msg) {
- 19 console.error(`tally-playtest: ${msg}`);
- 20 process.exit(1);
- 21}
- 23const args = process.argv.slice(2);
- 24let jsonPath = null;
- 25const pos = [];
- 26for (let i = 0; i < args.length; i++) {
- 27 if (args[i] === "--json") jsonPath = args[++i] ?? fail("--json needs a path");
- 28 else pos.push(args[i]);
- 29}
- 30const [inPath, mdPath] = pos;
- 31if (!inPath || !mdPath) fail("usage: node scripts/tally-playtest.mjs data/playtest.csv out/playtest-summary.md [--json out/playtest-summary.json]");
- 33function parseCsv(text) {
- 34 const rows = [];
- 35 let row = [], field = "", quoted = false;
- 36 for (let i = 0; i < text.length; i++) {
- 37 const c = text[i];
- 38 if (quoted) {
- 39 if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
- 40 else if (c === '"') quoted = false;
- 41 else field += c;
- 42 } else if (c === '"') quoted = true;
- 43 else if (c === ",") { row.push(field); field = ""; }
- 44 else if (c === "\n" || c === "\r") {
- 45 if (c === "\r" && text[i + 1] === "\n") i++;
- 46 row.push(field); field = "";
- 47 if (row.some((f) => f.trim() !== "")) rows.push(row);
- 48 row = [];
- 49 } else field += c;
- 50 }
- 51 row.push(field);
- 52 if (row.some((f) => f.trim() !== "")) rows.push(row);
- 53 return rows;
- 54}
- 56let text;
- 57try { text = readFileSync(inPath, "utf8").replace(new RegExp("^" + String.fromCharCode(0xfeff)), ""); } catch { fail(`cannot read ${inPath}`); }
- 58const rows = parseCsv(text);
- 59if (rows.length < 2) fail(`${inPath} needs a header row and at least one note`);
- 60const header = rows[0].map((h) => h.trim().toLowerCase());
- 61const col = (name) => header.indexOf(name);
- 62for (const need of ["tester", "type", "note"]) if (col(need) < 0) fail(`missing column "${need}" (found: ${header.join(", ")})`);
- 64const skipped = [];
- 65const notes = [];
- 66rows.slice(1).forEach((r, i) => {
- 67 const get = (name) => (col(name) >= 0 ? (r[col(name)] ?? "").trim() : "");
- 68 const type = get("type").toLowerCase();
- 69 const line = i + 2;
- 70 if (!TYPES.includes(type)) { skipped.push({ line, reason: `unknown type "${get("type")}"` }); return; }
- 71 const sevRaw = get("severity");
- 72 let severity = Number(sevRaw);
- 73 if (!PROBLEM_TYPES.has(type)) severity = 0;
- 74 else if (![1, 2, 3].includes(severity)) {
- 75 if (sevRaw) skipped.push({ line, reason: `severity "${sevRaw}" is not 1, 2, or 3; counted as 1` });
- 76 severity = 1;
- 77 }
- 78 const tag = get("tag").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
- 79 notes.push({ line, tester: get("tester") || "unknown", level: get("level") || "unspecified", type, severity, tag, note: get("note") });
- 80});
- 82const testers = [...new Set(notes.map((n) => n.tester))];
- 83const byType = Object.fromEntries(TYPES.map((t) => [t, notes.filter((n) => n.type === t).length]));
- 85// Problems grouped by tag, ranked by distinct testers x worst severity.
- 86const groups = new Map();
- 87for (const n of notes.filter((n) => PROBLEM_TYPES.has(n.type) && n.tag)) {
- 88 const g = groups.get(n.tag) ?? { tag: n.tag, notes: [] };
- 89 g.notes.push(n);
- 90 groups.set(n.tag, g);
- 91}
- 92const mostCommon = (list) => {
- 93 const counts = new Map();
- 94 for (const v of list) counts.set(v, (counts.get(v) ?? 0) + 1);
- 95 return [...counts.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))[0][0];
- 96};
- 97const issues = [...groups.values()].map((g) => {
- 98 const who = new Set(g.notes.map((n) => n.tester));
- 99 const maxSeverity = Math.max(...g.notes.map((n) => n.severity));
- 100 return {
- 101 tag: g.tag,
- 102 type: mostCommon(g.notes.map((n) => n.type)),
- 103 testers: who.size,
- 104 max_severity: maxSeverity,
- 105 score: who.size * maxSeverity,
- 106 levels: [...new Set(g.notes.map((n) => n.level))],
- 107 example: g.notes.slice().sort((a, b) => b.severity - a.severity)[0].note,
- 108 };
- 109}).sort((a, b) => b.score - a.score || b.testers - a.testers || a.tag.localeCompare(b.tag));
- 111const untagged = notes.filter((n) => PROBLEM_TYPES.has(n.type) && !n.tag);
- 112const levels = [...new Set(notes.map((n) => n.level))].map((level) => {
- 113 const here = notes.filter((n) => n.level === level);
- 114 return { level, notes: here.length, ...Object.fromEntries(TYPES.map((t) => [t, here.filter((n) => n.type === t).length])) };
- 115}).sort((a, b) => (b.bug + b.confusion + b.frustration + b.boredom) - (a.bug + a.confusion + a.frustration + a.boredom) || a.level.localeCompare(b.level));
- 117const summary = { testers: testers.length, notes: notes.length, skipped: skipped.length, by_type: byType, issues: issues.length };
- 118const result = {
- 119 summary,
- 120 issues,
- 121 levels,
- 122 untagged: untagged.map(({ line, tester, level, type, severity, note }) => ({ line, tester, level, type, severity, note })),
- 123 delights: notes.filter((n) => n.type === "delight").map(({ tester, level, note }) => ({ tester, level, note })),
- 124 ideas: notes.filter((n) => n.type === "idea").map(({ tester, level, note }) => ({ tester, level, note })),
- 125 skipped,
- 126};
- 128const cell = (s) => String(s).replace(/\|/g, "\\|").replace(/\s+/g, " ");
- 129const md = [];
- 130md.push("# Playtest summary", "");
- 131md.push(`Testers: ${summary.testers} | Notes: ${summary.notes} | Bugs: ${byType.bug} | Confusion: ${byType.confusion} | Frustration: ${byType.frustration} | Boredom: ${byType.boredom} | Delight: ${byType.delight} | Ideas: ${byType.idea}`, "");
- 132md.push("## Top issues", "", "Ranked by testers who hit it times worst severity (3 blocks progress).", "");
- 133md.push("| # | Issue | Type | Testers | Worst | Where | In their words |", "| - | ----- | ---- | ------- | ----- | ----- | -------------- |");
- 134issues.forEach((it, i) => md.push(`| ${i + 1} | ${it.tag} | ${it.type} | ${it.testers}/${summary.testers} | ${it.max_severity} | ${cell(it.levels.join(", "))} | ${cell(it.example)} |`));
- 135if (issues.length === 0) md.push("| - | none tagged | | | | | |");
- 136md.push("", "## By level", "", "| Level | Notes | Bug | Confusion | Frustration | Boredom | Delight |", "| ----- | ----- | --- | --------- | ----------- | ------- | ------- |");
- 137for (const l of levels) md.push(`| ${cell(l.level)} | ${l.notes} | ${l.bug} | ${l.confusion} | ${l.frustration} | ${l.boredom} | ${l.delight} |`);
- 138md.push("", "## Protect these (delight)", "");
- 139for (const d of result.delights) md.push(`- ${d.level}: ${d.note} (${d.tester})`);
- 140if (result.delights.length === 0) md.push("- none recorded");
- 141md.push("", "## Ideas from testers", "");
- 142for (const d of result.ideas) md.push(`- ${d.level}: ${d.note} (${d.tester})`);
- 143if (result.ideas.length === 0) md.push("- none recorded");
- 144if (untagged.length) {
- 145 md.push("", "## Untagged problems", "", "Give these a tag and rerun so they join the ranking.", "");
- 146 for (const n of untagged) md.push(`- line ${n.line}, ${n.level}, ${n.type} ${n.severity}: ${n.note}`);
- 147}
- 148if (skipped.length) {
- 149 md.push("", "## Rows needing a look", "");
- 150 for (const s of skipped) md.push(`- line ${s.line}: ${s.reason}`);
- 151}
- 153mkdirSync(dirname(mdPath), { recursive: true });
- 154writeFileSync(mdPath, md.join("\n") + "\n");
- 155if (jsonPath) {
- 156 mkdirSync(dirname(jsonPath), { recursive: true });
- 157 writeFileSync(jsonPath, JSON.stringify(result, null, 2) + "\n");
- 158}
- 159console.log(`${summary.notes} notes from ${summary.testers} testers, ${issues.length} tagged issues. Wrote ${mdPath}${jsonPath ? ` and ${jsonPath}` : ""}.`);
- 160if (issues[0]) console.log(`Top issue: ${issues[0].tag} (${issues[0].testers}/${summary.testers} testers, worst ${issues[0].max_severity})`);
