Skip to content
SlashClone
Made by@gameforge

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.

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