Level designer
@gameforge/level-designer
Design tile-grid game levels as plain JSON and check them with a validator: equal-width rows, known tiles, one start, an exit you can actually reach (keys and doors included), reachable coins, and a closed border. Writes a JSON report you can keep in CI.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Validate tile-grid levels stored as JSON.
- 3// Usage: node scripts/validate-levels.mjs <file.json|folder>... [--report out/level-report.json] [--exit-zero]
- 4//
- 5// Level format:
- 6// { "name": "1-1 First steps", "tiles": ["#####", "#S.E#", "#####"] }
- 7// Legend: # wall, . floor, S start (exactly one), E exit (one or more), ^ spikes (blocks the path),
- 8// C coin, K key, D door (opens with any key picked up earlier).
- 9// Exit code: 0 when every level has no errors, 1 when any level has errors (unless --exit-zero), 2 on bad usage.
- 10import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
- 11import { dirname, join } from "node:path";
- 13const LEGEND = { "#": "wall", ".": "floor", S: "start", E: "exit", "^": "spikes", C: "coin", K: "key", D: "door" };
- 14const MAX_SIDE = 128;
- 15const MIN_SIDE = 3;
- 17function usage(msg) {
- 18 if (msg) console.error(`validate-levels: ${msg}`);
- 19 console.error("Usage: node scripts/validate-levels.mjs <file.json|folder>... [--report out/level-report.json] [--exit-zero]");
- 20 process.exit(2);
- 21}
- 23const args = process.argv.slice(2);
- 24let reportPath = null;
- 25let exitZero = false;
- 26const targets = [];
- 27for (let i = 0; i < args.length; i++) {
- 28 if (args[i] === "--report") reportPath = args[++i] ?? usage("--report needs a path");
- 29 else if (args[i] === "--exit-zero") exitZero = true;
- 30 else if (args[i].startsWith("--")) usage(`unknown option ${args[i]}`);
- 31 else targets.push(args[i]);
- 32}
- 33if (targets.length === 0) usage("give at least one level file or folder");
- 35const files = [];
- 36for (const t of targets) {
- 37 if (!existsSync(t)) usage(`${t} does not exist`);
- 38 if (statSync(t).isDirectory()) {
- 39 for (const f of readdirSync(t).sort()) if (f.endsWith(".json")) files.push(join(t, f));
- 40 } else files.push(t);
- 41}
- 42if (files.length === 0) usage("no .json level files found");
- 44/** Breadth-first search over (x, y, hasKey). Returns distances keyed by "x,y,k". */
- 45function explore(grid, start) {
- 46 const h = grid.length, w = grid[0].length;
- 47 const dist = new Map();
- 48 const key = (x, y, k) => `${x},${y},${k}`;
- 49 const queue = [[start.x, start.y, 0]];
- 50 dist.set(key(start.x, start.y, 0), 0);
- 51 for (let head = 0; head < queue.length; head++) {
- 52 const [x, y, k] = queue[head];
- 53 const d = dist.get(key(x, y, k));
- 54 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
- 55 const nx = x + dx, ny = y + dy;
- 56 if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; // outside the grid counts as wall
- 57 const c = grid[ny][nx];
- 58 if (c === "#" || c === "^") continue;
- 59 if (c === "D" && !k) continue;
- 60 const nk = k || c === "K" ? 1 : 0;
- 61 const id = key(nx, ny, nk);
- 62 if (dist.has(id)) continue;
- 63 dist.set(id, d + 1);
- 64 queue.push([nx, ny, nk]);
- 65 }
- 66 }
- 67 return dist;
- 68}
- 70function reachedAt(dist, x, y) {
- 71 const a = dist.get(`${x},${y},0`), b = dist.get(`${x},${y},1`);
- 72 if (a === undefined) return b;
- 73 if (b === undefined) return a;
- 74 return Math.min(a, b);
- 75}
- 77function check(file) {
- 78 const errors = [];
- 79 const warnings = [];
- 80 const result = { file, name: null, errors, warnings, stats: {} };
- 81 let level;
- 82 try {
- 83 level = JSON.parse(readFileSync(file, "utf8"));
- 84 } catch (e) {
- 85 errors.push(`not valid JSON: ${e.message}`);
- 86 return result;
- 87 }
- 88 result.name = typeof level?.name === "string" ? level.name : null;
- 89 if (!result.name) warnings.push('no "name": add one so playtest notes can refer to it');
- 90 const tiles = level?.tiles;
- 91 if (!Array.isArray(tiles) || tiles.length === 0 || !tiles.every((r) => typeof r === "string")) {
- 92 errors.push('"tiles" must be a non-empty array of strings, one per row');
- 93 return result;
- 94 }
- 95 const h = tiles.length, w = tiles[0].length;
- 96 result.stats.width = w;
- 97 result.stats.height = h;
- 98 tiles.forEach((row, y) => {
- 99 if (row.length !== w) errors.push(`row ${y} is ${row.length} wide, expected ${w} (all rows must be the same width)`);
- 100 });
- 101 if (w < MIN_SIDE || h < MIN_SIDE) errors.push(`level is ${w}x${h}; the smallest allowed is ${MIN_SIDE}x${MIN_SIDE}`);
- 102 if (w > MAX_SIDE || h > MAX_SIDE) errors.push(`level is ${w}x${h}; the largest allowed is ${MAX_SIDE}x${MAX_SIDE}`);
- 103 if (errors.length) return result;
- 105 const found = { S: [], E: [], C: [], K: [], D: [] };
- 106 const unknown = new Set();
- 107 tiles.forEach((row, y) => {
- 108 [...row].forEach((c, x) => {
- 109 if (!(c in LEGEND)) unknown.add(`"${c}" at row ${y}, column ${x}`);
- 110 if (c in found) found[c].push({ x, y });
- 111 });
- 112 });
- 113 for (const u of unknown) errors.push(`unknown tile ${u}`);
- 114 if (found.S.length !== 1) errors.push(`needs exactly one start (S), found ${found.S.length}`);
- 115 if (found.E.length === 0) errors.push("needs at least one exit (E)");
- 116 if (found.D.length > 0 && found.K.length === 0) errors.push("has a door (D) but no key (K)");
- 118 let open = 0;
- 119 for (let x = 0; x < w; x++) for (const y of [0, h - 1]) if (tiles[y][x] !== "#") open++;
- 120 for (let y = 1; y < h - 1; y++) for (const x of [0, w - 1]) if (tiles[y][x] !== "#") open++;
- 121 if (open > 0) warnings.push(`border has ${open} open tile(s); the validator treats the outside as wall, but your engine may let the player walk off the map`);
- 123 result.stats.coins = found.C.length;
- 124 if (errors.length || found.S.length !== 1) return result;
- 126 const dist = explore(tiles, found.S[0]);
- 127 const exitSteps = found.E.map((e) => reachedAt(dist, e.x, e.y)).filter((d) => d !== undefined);
- 128 if (exitSteps.length === 0) {
- 129 errors.push(found.D.length ? "no exit is reachable from the start, even after picking up a key" : "no exit is reachable from the start");
- 130 } else {
- 131 result.stats.shortest_path = Math.min(...exitSteps);
- 132 if (result.stats.shortest_path < 3) warnings.push(`exit is only ${result.stats.shortest_path} step(s) from the start`);
- 133 }
- 134 const unreachable = (list) => list.filter((p) => reachedAt(dist, p.x, p.y) === undefined);
- 135 const lostCoins = unreachable(found.C);
- 136 result.stats.coins_reachable = found.C.length - lostCoins.length;
- 137 for (const p of lostCoins) warnings.push(`coin at row ${p.y}, column ${p.x} cannot be reached`);
- 138 for (const p of unreachable(found.K)) warnings.push(`key at row ${p.y}, column ${p.x} cannot be reached`);
- 139 return result;
- 140}
- 142const levels = files.map(check);
- 143const summary = {
- 144 levels: levels.length,
- 145 valid: levels.filter((l) => l.errors.length === 0).length,
- 146 invalid: levels.filter((l) => l.errors.length > 0).length,
- 147 warnings: levels.reduce((n, l) => n + l.warnings.length, 0),
- 148};
- 149const report = { summary, levels };
- 151for (const l of levels) {
- 152 const tag = l.errors.length ? "FAIL" : "ok ";
- 153 const steps = l.stats.shortest_path !== undefined ? `, exit in ${l.stats.shortest_path} steps` : "";
- 154 console.log(`${tag} ${l.file}${l.name ? ` (${l.name})` : ""}${steps}`);
- 155 for (const e of l.errors) console.log(` error: ${e}`);
- 156 for (const w of l.warnings) console.log(` warning: ${w}`);
- 157}
- 158console.log(`${summary.valid}/${summary.levels} levels valid, ${summary.warnings} warning(s)`);
- 160if (reportPath) {
- 161 mkdirSync(dirname(reportPath), { recursive: true });
- 162 writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
- 163 console.log(`Report: ${reportPath}`);
- 164}
- 165process.exit(summary.invalid > 0 && !exitZero ? 1 : 0);
