@ludra/cli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  export declare function check(dir: string, options: {
2
2
  json?: boolean;
3
- }): number;
3
+ }): Promise<number>;
@@ -1,8 +1,9 @@
1
1
  import { formatDiagnostics, paint } from "../format.js";
2
- import { loadProject, readScene } from "../project.js";
3
- export function check(dir, options) {
2
+ import { loadGame, loadProject } from "../project.js";
3
+ export async function check(dir, options) {
4
4
  const project = loadProject(dir);
5
- const { scene, diagnostics } = readScene(project);
5
+ const { game, diagnostics } = await loadGame(project);
6
+ const scene = game?.scene;
6
7
  const errors = diagnostics.filter((d) => d.severity === "error").length;
7
8
  const warnings = diagnostics.length - errors;
8
9
  if (options.json) {
@@ -1,4 +1,4 @@
1
- import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SOUND_PRESETS, } from "@ludra/core";
1
+ import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SCRIPT_API_REFERENCE, SOUND_PRESETS, } from "@ludra/core";
2
2
  import { pad, paint } from "../format.js";
3
3
  import { CliError } from "../project.js";
4
4
  const EFFECTS_EXAMPLE = `"effects": {
@@ -23,6 +23,10 @@ export function components(name, options) {
23
23
  if (spec.requires?.length)
24
24
  console.log(paint("dim", ` requires: ${spec.requires.join(", ")}`));
25
25
  printFields(spec.fields);
26
+ if (n === "script" && name === "script")
27
+ console.log(`\n${SCRIPT_API_REFERENCE.replace(/^/gm, " ")}`);
28
+ else if (n === "script")
29
+ console.log(paint("dim", " Run `ludra components script` for the script API."));
26
30
  console.log();
27
31
  }
28
32
  if (showEffects) {
@@ -1,8 +1,4 @@
1
- /**
2
- * Serves the game with the engine's browser runtime and reloads the page when
3
- * the scene file changes. No bundler: when the engine runs from source, its
4
- * TypeScript files are type-stripped on the fly.
5
- */
1
+ /** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
6
2
  export declare function dev(dir: string, options: {
7
3
  port?: string;
8
4
  }): number;
@@ -1,66 +1,13 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { createServer } from "node:http";
3
- import { stripTypeScriptTypes } from "node:module";
4
- import { dirname, extname, join, relative, resolve, sep } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { collectAssetPaths } from "@ludra/core";
7
1
  import { paint } from "../format.js";
8
- import { CliError, isInside, loadProject, readScene } from "../project.js";
9
- /**
10
- * Serves the game with the engine's browser runtime and reloads the page when
11
- * the scene file changes. No bundler: when the engine runs from source, its
12
- * TypeScript files are type-stripped on the fly.
13
- */
2
+ import { CliError, loadProject } from "../project.js";
3
+ import { createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "../server.js";
4
+ /** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
14
5
  export function dev(dir, options) {
15
6
  const project = loadProject(dir);
16
7
  const port = Number(options.port ?? 5173);
17
8
  if (!Number.isInteger(port) || port <= 0)
18
9
  throw new CliError(`Invalid --port "${options.port}".`);
19
- const coreEntry = fileURLToPath(import.meta.resolve("@ludra/core/browser"));
20
- const coreDir = dirname(coreEntry);
21
- const browserModule = `/__ludra/core/${relative(coreDir, coreEntry).replaceAll(sep, "/")}`;
22
- const server = createServer((req, res) => {
23
- const url = new URL(req.url ?? "/", "http://localhost");
24
- try {
25
- if (url.pathname === "/") {
26
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
27
- res.end(page(project, browserModule));
28
- }
29
- else if (url.pathname === "/__ludra/scene") {
30
- const { scene, diagnostics } = readScene(project);
31
- res.writeHead(200, { "content-type": "application/json" });
32
- res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
33
- }
34
- else if (url.pathname === "/__ludra/version") {
35
- res.writeHead(200, { "content-type": "text/plain" });
36
- res.end(version(project));
37
- }
38
- else if (url.pathname.startsWith(PROJECT_PREFIX)) {
39
- // Only media files inside the project are served, never source or config.
40
- const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
41
- const type = MEDIA_TYPES[extname(file).toLowerCase()];
42
- if (!type || !isInside(project.dir, file) || !existsSync(file))
43
- return notFound(res);
44
- res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
45
- res.end(readFileSync(file));
46
- }
47
- else if (url.pathname.startsWith("/__ludra/core/")) {
48
- const file = join(coreDir, decodeURIComponent(url.pathname.slice("/__ludra/core/".length)));
49
- if (!file.startsWith(coreDir + sep) || !existsSync(file))
50
- return notFound(res);
51
- const source = readFileSync(file, "utf8");
52
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
53
- res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
54
- }
55
- else {
56
- notFound(res);
57
- }
58
- }
59
- catch (err) {
60
- res.writeHead(500, { "content-type": "text/plain" });
61
- res.end(err.message);
62
- }
63
- });
10
+ const { server } = createGameServer(project, (browserModule) => page(project, browserModule));
64
11
  server.on("error", (err) => {
65
12
  console.error(paint("red", err.code === "EADDRINUSE" ? `Port ${port} is in use. Try --port ${port + 1}.` : err.message));
66
13
  process.exit(1);
@@ -72,32 +19,6 @@ export function dev(dir, options) {
72
19
  });
73
20
  return 0;
74
21
  }
75
- const PROJECT_PREFIX = "/__ludra/project/";
76
- const MEDIA_TYPES = {
77
- ".svg": "image/svg+xml",
78
- ".png": "image/png",
79
- ".jpg": "image/jpeg",
80
- ".jpeg": "image/jpeg",
81
- ".gif": "image/gif",
82
- ".webp": "image/webp",
83
- ".wav": "audio/wav",
84
- ".mp3": "audio/mpeg",
85
- ".ogg": "audio/ogg",
86
- };
87
- /** Changes whenever the scene or any file it references changes, so the page knows to reload. */
88
- function version(project) {
89
- if (!existsSync(project.entryPath))
90
- return "missing";
91
- const files = [project.entryPath];
92
- const { scene } = readScene(project);
93
- if (scene)
94
- files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
95
- return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
96
- }
97
- function notFound(res) {
98
- res.writeHead(404, { "content-type": "text/plain" });
99
- res.end("Not found");
100
- }
101
22
  function page(project, browserModule) {
102
23
  const { width, height } = project.config.window;
103
24
  const title = escapeHtml(project.config.name);
@@ -122,7 +43,8 @@ import { startGame } from "${browserModule}";
122
43
 
123
44
  const data = await (await fetch("/__ludra/scene")).json();
124
45
  if (data.ok) {
125
- await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}" });
46
+ const scriptPaths = await (await fetch("/__ludra/scripts")).json();
47
+ await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}", scriptBase: "${SCRIPT_PREFIX}", scriptPaths });
126
48
  } else {
127
49
  document.getElementById("game").hidden = true;
128
50
  const errors = document.getElementById("errors");
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { paint } from "../format.js";
@@ -19,6 +19,10 @@ export function create(dir, options) {
19
19
  }
20
20
  const name = basename(target);
21
21
  cpSync(source, target, { recursive: true });
22
+ // npm never publishes files named .gitignore, so templates ship it as _gitignore.
23
+ const gitignore = join(target, "_gitignore");
24
+ if (existsSync(gitignore))
25
+ renameSync(gitignore, join(target, ".gitignore"));
22
26
  for (const file of listFiles(target)) {
23
27
  const text = readFileSync(file, "utf8");
24
28
  if (text.includes("{{name}}"))
@@ -5,6 +5,8 @@ export interface PlayOptions {
5
5
  every?: string;
6
6
  ascii?: boolean;
7
7
  columns?: string;
8
+ screenshot?: string;
9
+ out?: string;
8
10
  json?: boolean;
9
11
  }
10
- export declare function play(dir: string, options: PlayOptions): number;
12
+ export declare function play(dir: string, options: PlayOptions): Promise<number>;
@@ -1,14 +1,17 @@
1
- import { defaultDuration, InputScript, parseDuration, renderAscii, secondsToFrames, simulate, World, } from "@ludra/core";
1
+ import { defaultDuration, InputScript, parseDuration, renderAscii, ScriptError, secondsToFrames, simulate, World, } from "@ludra/core";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
2
4
  import { fixed, pad, padStart, paint } from "../format.js";
3
- import { CliError, loadProject, requireScene } from "../project.js";
5
+ import { captureScreenshots } from "../headless.js";
6
+ import { CliError, loadProject, requireGame } from "../project.js";
4
7
  const MAX_EVENTS_SHOWN = 40;
5
- export function play(dir, options) {
8
+ export async function play(dir, options) {
6
9
  const project = loadProject(dir);
7
- const scene = requireScene(project);
10
+ const { scene, scripts } = await requireGame(project);
8
11
  const script = parse(() => InputScript.parse(options.inputs ?? ""));
9
12
  const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
10
13
  const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
11
- const world = new World(scene);
14
+ const world = scriptErrors(() => new World(scene, { scripts }));
12
15
  const watch = options.watch
13
16
  ? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
14
17
  : world.entities.filter((e) => e.platformer).map((e) => e.id);
@@ -18,19 +21,20 @@ export function play(dir, options) {
18
21
  }
19
22
  }
20
23
  const rows = [];
21
- simulate(world, script, frames, (w) => {
24
+ scriptErrors(() => simulate(world, script, frames, (w) => {
22
25
  if (w.frame % every !== 0 && w.frame !== frames)
23
26
  return;
24
27
  const snapshot = w.snapshot();
25
28
  const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
26
29
  rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
27
- });
30
+ }));
28
31
  const columns = Number(options.columns ?? 80);
29
32
  if (!Number.isInteger(columns) || columns < 10)
30
33
  throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
31
34
  const ascii = options.ascii ? renderAscii(world, { columns }) : undefined;
35
+ const screenshots = options.screenshot ? await takeScreenshots(project, options, frames) : undefined;
32
36
  if (options.json) {
33
- console.log(JSON.stringify({ duration: frames / 60, frames, inputs: options.inputs ?? "", timeline: rows, events: world.events, final: world.snapshot(), ascii }, null, 2));
37
+ console.log(JSON.stringify({ duration: frames / 60, frames, inputs: options.inputs ?? "", timeline: rows, events: world.events, final: world.snapshot(), ascii, screenshots }, null, 2));
34
38
  return 0;
35
39
  }
36
40
  console.log(paint("bold", `▶ ${project.config.name}: simulated ${fixed(frames / 60, 2)}s (${frames} frames)`) +
@@ -42,8 +46,38 @@ export function play(dir, options) {
42
46
  console.log(`\n${paint("bold", "Final:")} ${outcome} · score ${world.score} · deaths ${world.deaths} · ${world.entities.length} entities`);
43
47
  if (ascii)
44
48
  console.log(`\n${ascii}`);
49
+ if (screenshots) {
50
+ console.log(`\n${paint("bold", "Screenshots")} ${paint("dim", "(open these PNGs to see the frames)")}`);
51
+ for (const shot of screenshots)
52
+ console.log(` ${pad(`${fixed(shot.time, 2)}s`, 7)} ${shot.file}`);
53
+ }
45
54
  return 0;
46
55
  }
56
+ /**
57
+ * Renders moments of the same run in a headless browser and writes them as
58
+ * PNGs. `--screenshot` is a comma list of times and/or "end".
59
+ */
60
+ async function takeScreenshots(project, options, lastFrame) {
61
+ const requested = options.screenshot.split(",").map((part) => {
62
+ const token = part.trim();
63
+ if (token === "end")
64
+ return lastFrame;
65
+ const frame = secondsToFrames(parse(() => parseDuration(token)));
66
+ if (frame > lastFrame) {
67
+ throw new CliError(`--screenshot ${token} is after the end of the run (${fixed(lastFrame / 60, 2)}s). Use a longer --duration.`);
68
+ }
69
+ return frame;
70
+ });
71
+ const frames = [...new Set(requested)].sort((a, b) => a - b);
72
+ const outDir = resolve(project.dir, options.out ?? "screenshots");
73
+ mkdirSync(outDir, { recursive: true });
74
+ const captured = await captureScreenshots(project, { inputs: options.inputs ?? "", frames });
75
+ return captured.map((shot) => {
76
+ const path = join(outDir, `t-${fixed(shot.time, 2)}s.png`);
77
+ writeFileSync(path, Buffer.from(shot.png.slice(shot.png.indexOf(",") + 1), "base64"));
78
+ return { time: shot.time, frame: shot.frame, file: relative(process.cwd(), path).replaceAll("\\", "/") };
79
+ });
80
+ }
47
81
  function printTimeline(rows, watch) {
48
82
  const idWidth = Math.max(6, ...watch.map((id) => id.length));
49
83
  console.log(paint("dim", `\n ${pad("time", 7)} ${pad("input", 14)} ${pad("entity", idWidth)} ${padStart("x", 8)} ${padStart("y", 8)} ${padStart("vx", 8)} ${padStart("vy", 8)} grounded`));
@@ -62,7 +96,8 @@ function printTimeline(rows, watch) {
62
96
  function printEvents(events) {
63
97
  console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
64
98
  for (const event of events.slice(0, MAX_EVENTS_SHOWN)) {
65
- console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(event.type, 8)} ${describeEvent(event)}`);
99
+ const name = event.type === "custom" ? event.name : event.type;
100
+ console.log(` ${pad(`${fixed(event.time, 2)}s`, 7)} ${pad(name, 9)} ${describeEvent(event)}`);
66
101
  }
67
102
  if (events.length > MAX_EVENTS_SHOWN)
68
103
  console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
@@ -76,11 +111,24 @@ function describeEvent(event) {
76
111
  case "die":
77
112
  return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
78
113
  case "win":
79
- return `${event.entity} reached ${event.goal}`;
114
+ return event.entity === event.goal ? `${event.entity} (script) completed the level` : `${event.entity} reached ${event.goal}`;
115
+ case "custom":
116
+ return `from ${event.entity}'s script${event.data === undefined ? "" : ` ${JSON.stringify(event.data)}`}`;
80
117
  default:
81
118
  return event.entity;
82
119
  }
83
120
  }
121
+ /** Reports a failing script as a CLI error naming the file, entity, hook and frame. */
122
+ function scriptErrors(fn) {
123
+ try {
124
+ return fn();
125
+ }
126
+ catch (err) {
127
+ if (err instanceof ScriptError)
128
+ throw new CliError(`Script error in ${err.message}`);
129
+ throw err;
130
+ }
131
+ }
84
132
  /** Turns engine parse errors into user-facing CLI errors. */
85
133
  function parse(fn) {
86
134
  try {
@@ -1,3 +1,3 @@
1
1
  export declare function test(dir: string, options: {
2
2
  json?: boolean;
3
- }): number;
3
+ }): Promise<number>;
@@ -2,10 +2,10 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { runTest } from "@ludra/core";
4
4
  import { paint } from "../format.js";
5
- import { loadProject, requireScene } from "../project.js";
6
- export function test(dir, options) {
5
+ import { loadProject, requireGame } from "../project.js";
6
+ export async function test(dir, options) {
7
7
  const project = loadProject(dir);
8
- const scene = requireScene(project);
8
+ const { scene, scripts } = await requireGame(project);
9
9
  const testsDir = join(project.dir, "tests");
10
10
  const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
11
11
  if (files.length === 0) {
@@ -27,7 +27,7 @@ export function test(dir, options) {
27
27
  catch (err) {
28
28
  return { file, error: err.message, tests: [] };
29
29
  }
30
- return { file, tests: cases.map((c) => runTest(scene, c)) };
30
+ return { file, tests: cases.map((c) => runTest(scene, c, { scripts })) };
31
31
  });
32
32
  const all = results.flatMap((r) => r.tests);
33
33
  const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
@@ -0,0 +1,16 @@
1
+ import type { CapturedFrame } from "@ludra/core/browser";
2
+ import { type Project } from "./project.ts";
3
+ /**
4
+ * Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
5
+ * Chrome, Edge or Chromium in their usual places.
6
+ */
7
+ export declare function findBrowser(): string | undefined;
8
+ export interface ScreenshotRequest {
9
+ inputs: string;
10
+ frames: number[];
11
+ }
12
+ /**
13
+ * Renders frames of a project in a headless browser, using the same renderer
14
+ * players see. Starts a private game server and browser, and stops both.
15
+ */
16
+ export declare function captureScreenshots(project: Project, request: ScreenshotRequest): Promise<CapturedFrame[]>;
@@ -0,0 +1,204 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { delimiter, join } from "node:path";
5
+ import { CliError } from "./project.js";
6
+ import { BLANK_PATH, createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "./server.js";
7
+ const TIMEOUT_MS = 60_000;
8
+ /**
9
+ * Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
10
+ * Chrome, Edge or Chromium in their usual places.
11
+ */
12
+ export function findBrowser() {
13
+ const fromEnv = process.env.LUDRA_BROWSER;
14
+ if (fromEnv)
15
+ return existsSync(fromEnv) ? fromEnv : undefined;
16
+ const candidates = [];
17
+ if (process.platform === "win32") {
18
+ for (const base of [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA]) {
19
+ if (!base)
20
+ continue;
21
+ candidates.push(join(base, "Google", "Chrome", "Application", "chrome.exe"), join(base, "Microsoft", "Edge", "Application", "msedge.exe"));
22
+ }
23
+ }
24
+ else if (process.platform === "darwin") {
25
+ candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge");
26
+ }
27
+ else {
28
+ const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"];
29
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
30
+ for (const name of names)
31
+ candidates.push(join(dir, name));
32
+ }
33
+ }
34
+ return candidates.find((path) => existsSync(path));
35
+ }
36
+ /**
37
+ * Renders frames of a project in a headless browser, using the same renderer
38
+ * players see. Starts a private game server and browser, and stops both.
39
+ */
40
+ export async function captureScreenshots(project, request) {
41
+ const browserPath = findBrowser();
42
+ if (!browserPath) {
43
+ throw new CliError("Screenshots need Chrome, Edge or Chromium installed, and none was found.\n" +
44
+ "Install one, or set LUDRA_BROWSER to the path of a Chromium-based browser.");
45
+ }
46
+ const { server, browserModule } = createGameServer(project, () => "");
47
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
48
+ const port = server.address().port;
49
+ const profile = mkdtempSync(join(tmpdir(), "ludra-browser-"));
50
+ let browser;
51
+ let cdp;
52
+ try {
53
+ const { width, height } = project.config.window;
54
+ browser = spawn(browserPath, [
55
+ "--headless=new",
56
+ "--remote-debugging-port=0",
57
+ `--user-data-dir=${profile}`,
58
+ "--no-first-run",
59
+ "--no-default-browser-check",
60
+ "--mute-audio",
61
+ "--hide-scrollbars",
62
+ `--window-size=${width},${height}`,
63
+ "about:blank",
64
+ ], { stdio: ["ignore", "ignore", "pipe"] });
65
+ cdp = await Cdp.connect(await devToolsUrl(browser));
66
+ const origin = `http://127.0.0.1:${port}`;
67
+ const { targetId } = await cdp.send("Target.createTarget", { url: origin + BLANK_PATH });
68
+ const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
69
+ // The new tab starts on about:blank; wait until it has loaded the game server's page.
70
+ await waitFor(cdp, sessionId, `location.origin === ${JSON.stringify(origin)} && document.readyState === "complete"`);
71
+ const script = `(async () => {
72
+ const { captureFrames } = await import(${JSON.stringify(browserModule)});
73
+ const data = await (await fetch("/__ludra/scene")).json();
74
+ if (!data.ok) throw new Error("The scene has errors. Run ludra check.");
75
+ const canvas = document.createElement("canvas");
76
+ canvas.width = ${width};
77
+ canvas.height = ${height};
78
+ document.body.append(canvas);
79
+ return captureFrames(canvas, data.scene, {
80
+ assetBase: ${JSON.stringify(PROJECT_PREFIX)},
81
+ scriptBase: ${JSON.stringify(SCRIPT_PREFIX)},
82
+ scriptPaths: await (await fetch("/__ludra/scripts")).json(),
83
+ inputs: ${JSON.stringify(request.inputs)},
84
+ frames: ${JSON.stringify(request.frames)},
85
+ });
86
+ })()`;
87
+ return await evaluate(cdp, sessionId, script);
88
+ }
89
+ finally {
90
+ await cdp?.send("Browser.close").catch(() => { });
91
+ cdp?.close();
92
+ if (browser && browser.exitCode === null) {
93
+ await new Promise((resolve) => {
94
+ const timer = setTimeout(() => {
95
+ browser.kill();
96
+ resolve(undefined);
97
+ }, 3000);
98
+ browser.once("exit", () => {
99
+ clearTimeout(timer);
100
+ resolve(undefined);
101
+ });
102
+ });
103
+ }
104
+ server.close();
105
+ // The browser can hold its profile for a moment after exiting on Windows.
106
+ rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
107
+ }
108
+ }
109
+ /** Chrome prints "DevTools listening on ws://..." to stderr once it is ready. */
110
+ function devToolsUrl(browser) {
111
+ return new Promise((resolve, reject) => {
112
+ let output = "";
113
+ const timer = setTimeout(() => reject(new CliError(`The browser did not start in time.\n${output.trim()}`)), TIMEOUT_MS);
114
+ browser.stderr.on("data", (chunk) => {
115
+ output += chunk.toString();
116
+ const match = /DevTools listening on (ws:\/\/\S+)/.exec(output);
117
+ if (match) {
118
+ clearTimeout(timer);
119
+ resolve(match[1]);
120
+ }
121
+ });
122
+ browser.once("exit", (code) => {
123
+ clearTimeout(timer);
124
+ reject(new CliError(`The browser exited before it was ready (code ${code}).\n${output.trim()}`));
125
+ });
126
+ browser.once("error", (err) => {
127
+ clearTimeout(timer);
128
+ reject(new CliError(`Could not start the browser: ${err.message}`));
129
+ });
130
+ });
131
+ }
132
+ async function waitFor(cdp, sessionId, condition) {
133
+ const deadline = Date.now() + TIMEOUT_MS;
134
+ while (!(await evaluate(cdp, sessionId, condition))) {
135
+ if (Date.now() > deadline)
136
+ throw new CliError("The browser did not load the game page in time.");
137
+ await new Promise((resolve) => setTimeout(resolve, 50));
138
+ }
139
+ }
140
+ async function evaluate(cdp, sessionId, expression) {
141
+ const result = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId);
142
+ if (result.exceptionDetails) {
143
+ const details = result.exceptionDetails;
144
+ throw new CliError(`Rendering failed in the browser: ${details.exception?.description ?? details.text ?? "unknown error"}`);
145
+ }
146
+ return result.result.value;
147
+ }
148
+ /** A minimal Chrome DevTools Protocol client over the WebSocket built into Node. */
149
+ class Cdp {
150
+ nextId = 1;
151
+ pending = new Map();
152
+ socket;
153
+ constructor(socket) {
154
+ this.socket = socket;
155
+ socket.addEventListener("message", (event) => {
156
+ const message = JSON.parse(String(event.data));
157
+ if (message.id === undefined)
158
+ return;
159
+ const waiter = this.pending.get(message.id);
160
+ if (!waiter)
161
+ return;
162
+ this.pending.delete(message.id);
163
+ if (message.error)
164
+ waiter.reject(new CliError(`Browser error: ${message.error.message}`));
165
+ else
166
+ waiter.resolve(message.result);
167
+ });
168
+ socket.addEventListener("close", () => {
169
+ for (const waiter of this.pending.values())
170
+ waiter.reject(new CliError("The browser closed unexpectedly."));
171
+ this.pending.clear();
172
+ });
173
+ }
174
+ static connect(url) {
175
+ return new Promise((resolve, reject) => {
176
+ const socket = new WebSocket(url);
177
+ socket.addEventListener("open", () => resolve(new Cdp(socket)), { once: true });
178
+ socket.addEventListener("error", () => reject(new CliError(`Could not connect to the browser at ${url}.`)), { once: true });
179
+ });
180
+ }
181
+ send(method, params = {}, sessionId) {
182
+ const id = this.nextId++;
183
+ return new Promise((resolve, reject) => {
184
+ const timer = setTimeout(() => {
185
+ this.pending.delete(id);
186
+ reject(new CliError(`The browser did not answer ${method} in time.`));
187
+ }, TIMEOUT_MS);
188
+ this.pending.set(id, {
189
+ resolve: (value) => {
190
+ clearTimeout(timer);
191
+ resolve(value);
192
+ },
193
+ reject: (err) => {
194
+ clearTimeout(timer);
195
+ reject(err);
196
+ },
197
+ });
198
+ this.socket.send(JSON.stringify({ id, method, params, sessionId }));
199
+ });
200
+ }
201
+ close() {
202
+ this.socket.close();
203
+ }
204
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,14 @@ import { play } from "./commands/play.js";
8
8
  import { test } from "./commands/test.js";
9
9
  import { paint } from "./format.js";
10
10
  import { CliError } from "./project.js";
11
+ // Ludra strips types from game scripts with Node's built-in stripTypeScriptTypes, which warns that it is
12
+ // experimental on every use. That warning is noise for game authors, so drop just that one.
13
+ const emitWarning = process.emitWarning.bind(process);
14
+ process.emitWarning = ((warning, ...rest) => {
15
+ if (String(warning instanceof Error ? warning.message : warning).includes("stripTypeScriptTypes"))
16
+ return;
17
+ return emitWarning(warning, ...rest);
18
+ });
11
19
  const HELP = `ludra: the game engine built for AI agents
12
20
 
13
21
  Usage
@@ -20,13 +28,15 @@ Usage
20
28
  --every 0.25s Timeline sampling interval
21
29
  --ascii Print the final frame as a text map
22
30
  --columns 120 Width of the text map (default: 80)
31
+ --screenshot 1s,2.5s,end Save real rendered frames as PNGs (needs Chrome, Edge or Chromium)
32
+ --out screenshots Folder for screenshots, relative to the project (default: screenshots)
23
33
  --json Machine-readable output
24
34
  ludra test [folder] [--json] Run tests/*.test.json
25
35
  ludra components [name] [--json] Reference for every component and field
26
36
  ludra dev [folder] [--port 5173] Play the game in the browser with live reload
27
37
 
28
38
  Folder defaults to the current directory.`;
29
- function main(argv) {
39
+ async function main(argv) {
30
40
  const { values, positionals } = parseArgs({
31
41
  args: argv,
32
42
  allowPositionals: true,
@@ -37,6 +47,8 @@ function main(argv) {
37
47
  every: { type: "string" },
38
48
  ascii: { type: "boolean" },
39
49
  columns: { type: "string" },
50
+ screenshot: { type: "string" },
51
+ out: { type: "string" },
40
52
  json: { type: "boolean" },
41
53
  template: { type: "string" },
42
54
  port: { type: "string" },
@@ -66,7 +78,7 @@ function main(argv) {
66
78
  }
67
79
  }
68
80
  try {
69
- process.exitCode = main(process.argv.slice(2));
81
+ process.exitCode = await main(process.argv.slice(2));
70
82
  }
71
83
  catch (err) {
72
84
  // parseArgs reports unknown flags with a code; show those like our own errors.
package/dist/project.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Diagnostic, type SceneDef } from "@ludra/core";
1
+ import { type Diagnostic, type SceneDef, type Script } from "@ludra/core";
2
2
  /** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
3
3
  export declare class CliError extends Error {
4
4
  }
@@ -24,6 +24,22 @@ export interface SceneLoad {
24
24
  }
25
25
  /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
26
26
  export declare function readScene(project: Project): SceneLoad;
27
+ /** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
28
+ export declare function scriptPaths(project: Project, scene: SceneDef): string[];
29
+ export interface Game {
30
+ scene: SceneDef;
31
+ scripts: Map<string, Script>;
32
+ }
33
+ /**
34
+ * Reads the scene and imports its scripts. Node runs TypeScript scripts
35
+ * directly (type stripping), so no build step is needed.
36
+ */
37
+ export declare function loadGame(project: Project): Promise<{
38
+ game?: Game;
39
+ diagnostics: Diagnostic[];
40
+ }>;
41
+ /** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
42
+ export declare function requireGame(project: Project): Promise<Game>;
27
43
  export declare function isInside(dir: string, path: string): boolean;
28
44
  /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
29
45
  export declare function requireScene(project: Project): SceneDef;
package/dist/project.js CHANGED
@@ -1,6 +1,15 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
10
  import { isAbsolute, join, relative, resolve } from "node:path";
3
- import { collectAssetPaths, validateScene } from "@ludra/core";
11
+ import { pathToFileURL } from "node:url";
12
+ import { collectAssetPaths, findRuntimeImports, validateScene, validateScriptModule, } from "@ludra/core";
4
13
  import { formatDiagnostics } from "./format.js";
5
14
  /** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
6
15
  export class CliError extends Error {
@@ -61,25 +70,98 @@ function missingAssets(project, scene) {
61
70
  return collectAssetPaths(scene)
62
71
  .filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
63
72
  .map((ref) => {
64
- const available = listAssets(project.dir);
73
+ const folder = ref.kind === "script" ? "scripts" : "assets";
74
+ const available = listFiles(project.dir, folder);
65
75
  return {
66
76
  severity: "error",
67
77
  code: "E_ASSET_MISSING",
68
78
  path: ref.at,
69
79
  message: `The ${ref.kind} "${ref.path}" does not exist in the project.`,
70
- fix: available.length > 0 ? `Create it, or use one of: ${available.join(", ")}.` : `Create it (paths are relative to the folder with ludra.json, e.g. "assets/player.svg").`,
80
+ fix: available.length > 0
81
+ ? `Create it, or use one of: ${available.join(", ")}.`
82
+ : `Create it (paths are relative to the folder with ludra.json, e.g. "${folder}/${ref.kind === "script" ? "enemy.ts" : "player.svg"}").`,
71
83
  };
72
84
  });
73
85
  }
74
- function listAssets(dir) {
75
- const assetsDir = join(dir, "assets");
76
- if (!existsSync(assetsDir))
86
+ /** Files under a project folder, as project-relative paths with forward slashes. */
87
+ function listFiles(dir, folder) {
88
+ const root = join(dir, folder);
89
+ if (!existsSync(root))
77
90
  return [];
78
- return readdirSync(assetsDir, { recursive: true, withFileTypes: true })
91
+ return readdirSync(root, { recursive: true, withFileTypes: true })
79
92
  .filter((entry) => entry.isFile())
80
93
  .map((entry) => relative(dir, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
81
94
  .sort();
82
95
  }
96
+ /** Script files a game can use: those the scene references, plus everything in scripts/ (for world.spawn). */
97
+ export function scriptPaths(project, scene) {
98
+ const referenced = collectAssetPaths(scene).filter((ref) => ref.kind === "script").map((ref) => ref.path);
99
+ const inFolder = listFiles(project.dir, "scripts").filter((path) => /\.(ts|js)$/.test(path) && !path.endsWith(".d.ts"));
100
+ return [...new Set([...referenced, ...inFolder])];
101
+ }
102
+ /**
103
+ * Reads the scene and imports its scripts. Node runs TypeScript scripts
104
+ * directly (type stripping), so no build step is needed.
105
+ */
106
+ export async function loadGame(project) {
107
+ const { scene, diagnostics } = readScene(project);
108
+ if (!scene)
109
+ return { diagnostics };
110
+ const scripts = new Map();
111
+ for (const src of scriptPaths(project, scene)) {
112
+ const file = resolve(project.dir, src);
113
+ if (!isInside(project.dir, file) || !existsSync(file))
114
+ continue; // reported by readScene
115
+ const imports = findRuntimeImports(readFileSync(file, "utf8"));
116
+ if (imports.length > 0) {
117
+ diagnostics.push({
118
+ severity: "error",
119
+ code: "E_SCRIPT_IMPORT",
120
+ path: src,
121
+ message: `${src} imports ${imports.map((i) => `"${i}"`).join(", ")}. Scripts run without a bundler and cannot import runtime code.`,
122
+ fix: 'Use "import type" for types (e.g. import type { Script } from "@ludra/core"), and put shared helpers inside the script.',
123
+ });
124
+ continue;
125
+ }
126
+ let module;
127
+ try {
128
+ module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
129
+ }
130
+ catch (err) {
131
+ diagnostics.push({ severity: "error", code: "E_SCRIPT_LOAD", path: src, ...describeLoadError(err, src) });
132
+ continue;
133
+ }
134
+ const problems = validateScriptModule(module, src);
135
+ diagnostics.push(...problems);
136
+ if (!problems.some((d) => d.severity === "error"))
137
+ scripts.set(src, module.default);
138
+ }
139
+ const ok = !diagnostics.some((d) => d.severity === "error");
140
+ return { game: ok ? { scene, scripts } : undefined, diagnostics };
141
+ }
142
+ /**
143
+ * Node's syntax errors carry the location only in the stack: "file:///...:LINE", the source
144
+ * line, then a caret. Pull those out so the diagnostic points at the code.
145
+ */
146
+ function describeLoadError(err, src) {
147
+ const error = err;
148
+ const lines = String(error.stack ?? "").split("\n");
149
+ const at = lines.findIndex((line) => /^file:\/\/.*:\d+$/.test(line));
150
+ if (at < 0)
151
+ return { message: `${src} failed to load: ${error.message}` };
152
+ const line = lines[at].slice(lines[at].lastIndexOf(":") + 1);
153
+ const code = lines[at + 1] ?? "";
154
+ const caret = lines[at + 2] ?? "";
155
+ return { message: `${src}:${line} failed to load: ${error.message}`, fix: `Line ${line}:\n ${code}\n ${caret}` };
156
+ }
157
+ /** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
158
+ export async function requireGame(project) {
159
+ const { game, diagnostics } = await loadGame(project);
160
+ if (!game) {
161
+ throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
162
+ }
163
+ return game;
164
+ }
83
165
  export function isInside(dir, path) {
84
166
  const rel = relative(dir, path);
85
167
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
@@ -0,0 +1,18 @@
1
+ import { type Server } from "node:http";
2
+ import { type Project } from "./project.ts";
3
+ export declare const PROJECT_PREFIX = "/__ludra/project/";
4
+ export declare const SCRIPT_PREFIX = "/__ludra/script/";
5
+ /** An empty same-origin page that headless capture loads before importing the engine. */
6
+ export declare const BLANK_PATH = "/__ludra/blank";
7
+ export interface GameServer {
8
+ server: Server;
9
+ /** URL path of the engine's browser module, for `import()` in the page. */
10
+ browserModule: string;
11
+ }
12
+ /**
13
+ * Serves a project to a browser: the engine's browser runtime, the validated
14
+ * scene and the project's media. No bundler: when the engine runs from
15
+ * source, its TypeScript files are type-stripped on the fly.
16
+ * `indexHtml` builds the page served at "/".
17
+ */
18
+ export declare function createGameServer(project: Project, indexHtml: (browserModule: string) => string): GameServer;
package/dist/server.js ADDED
@@ -0,0 +1,112 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { stripTypeScriptTypes } from "node:module";
4
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { collectAssetPaths } from "@ludra/core";
7
+ import { isInside, readScene, scriptPaths } from "./project.js";
8
+ export const PROJECT_PREFIX = "/__ludra/project/";
9
+ export const SCRIPT_PREFIX = "/__ludra/script/";
10
+ /** An empty same-origin page that headless capture loads before importing the engine. */
11
+ export const BLANK_PATH = "/__ludra/blank";
12
+ const MEDIA_TYPES = {
13
+ ".svg": "image/svg+xml",
14
+ ".png": "image/png",
15
+ ".jpg": "image/jpeg",
16
+ ".jpeg": "image/jpeg",
17
+ ".gif": "image/gif",
18
+ ".webp": "image/webp",
19
+ ".wav": "audio/wav",
20
+ ".mp3": "audio/mpeg",
21
+ ".ogg": "audio/ogg",
22
+ };
23
+ /**
24
+ * Serves a project to a browser: the engine's browser runtime, the validated
25
+ * scene and the project's media. No bundler: when the engine runs from
26
+ * source, its TypeScript files are type-stripped on the fly.
27
+ * `indexHtml` builds the page served at "/".
28
+ */
29
+ export function createGameServer(project, indexHtml) {
30
+ const coreEntry = fileURLToPath(import.meta.resolve("@ludra/core/browser"));
31
+ const coreDir = dirname(coreEntry);
32
+ const browserModule = `/__ludra/core/${relative(coreDir, coreEntry).replaceAll(sep, "/")}`;
33
+ const server = createServer((req, res) => {
34
+ const url = new URL(req.url ?? "/", "http://localhost");
35
+ try {
36
+ if (url.pathname === "/") {
37
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
38
+ res.end(indexHtml(browserModule));
39
+ }
40
+ else if (url.pathname === BLANK_PATH) {
41
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
42
+ res.end('<!doctype html><html><head><meta charset="utf-8"><title>Ludra</title></head><body style="margin:0"></body></html>');
43
+ }
44
+ else if (url.pathname === "/__ludra/scene") {
45
+ const { scene, diagnostics } = readScene(project);
46
+ res.writeHead(200, { "content-type": "application/json" });
47
+ res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
48
+ }
49
+ else if (url.pathname === "/__ludra/version") {
50
+ res.writeHead(200, { "content-type": "text/plain" });
51
+ res.end(version(project));
52
+ }
53
+ else if (url.pathname === "/__ludra/scripts") {
54
+ const { scene } = readScene(project);
55
+ res.writeHead(200, { "content-type": "application/json" });
56
+ res.end(JSON.stringify(scene ? scriptPaths(project, scene) : []));
57
+ }
58
+ else if (url.pathname.startsWith(SCRIPT_PREFIX)) {
59
+ // Only the game's own script files, type-stripped so the browser can import them.
60
+ const path = decodeURIComponent(url.pathname.slice(SCRIPT_PREFIX.length));
61
+ const { scene } = readScene(project);
62
+ const file = resolve(project.dir, path);
63
+ if (!scene || !scriptPaths(project, scene).includes(path) || !isInside(project.dir, file) || !existsSync(file))
64
+ return notFound(res);
65
+ const source = readFileSync(file, "utf8");
66
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache" });
67
+ res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
68
+ }
69
+ else if (url.pathname.startsWith(PROJECT_PREFIX)) {
70
+ // Only media files inside the project are served, never source or config.
71
+ const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
72
+ const type = MEDIA_TYPES[extname(file).toLowerCase()];
73
+ if (!type || !isInside(project.dir, file) || !existsSync(file))
74
+ return notFound(res);
75
+ res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
76
+ res.end(readFileSync(file));
77
+ }
78
+ else if (url.pathname.startsWith("/__ludra/core/")) {
79
+ const file = join(coreDir, decodeURIComponent(url.pathname.slice("/__ludra/core/".length)));
80
+ if (!file.startsWith(coreDir + sep) || !existsSync(file))
81
+ return notFound(res);
82
+ const source = readFileSync(file, "utf8");
83
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
84
+ res.end(extname(file) === ".ts" ? stripTypeScriptTypes(source) : source);
85
+ }
86
+ else {
87
+ notFound(res);
88
+ }
89
+ }
90
+ catch (err) {
91
+ res.writeHead(500, { "content-type": "text/plain" });
92
+ res.end(err.message);
93
+ }
94
+ });
95
+ return { server, browserModule };
96
+ }
97
+ /** Changes whenever the scene or any file it references changes, so the page knows to reload. */
98
+ function version(project) {
99
+ if (!existsSync(project.entryPath))
100
+ return "missing";
101
+ const files = [project.entryPath];
102
+ const { scene } = readScene(project);
103
+ if (scene) {
104
+ files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
105
+ files.push(...scriptPaths(project, scene).map((path) => resolve(project.dir, path)));
106
+ }
107
+ return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
108
+ }
109
+ function notFound(res) {
110
+ res.writeHead(404, { "content-type": "text/plain" });
111
+ res.end("Not found");
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "The game engine built for AI agents. The ludra command: create, check, play, test and run Ludra games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "templates"
35
35
  ],
36
36
  "dependencies": {
37
- "@ludra/core": "0.3.1"
37
+ "@ludra/core": "0.5.0"
38
38
  },
39
39
  "keywords": [
40
40
  "game-engine",
@@ -7,13 +7,14 @@ A game built with Ludra, a game engine designed for AI agents.
7
7
  - `ludra.json`: project config (name, entry scene, window size)
8
8
  - `scenes/main.scene.json`: the level. Entities are made of components; `effects` adds particles, sound and shake to game events.
9
9
  - `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
10
+ - `scripts/`: custom game logic in TypeScript (create it when needed). Run `npx @ludra/cli components script` for the API.
10
11
  - `tests/*.test.json`: gameplay tests, run headlessly
11
12
 
12
13
  ## Loop
13
14
 
14
15
  1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field.
15
16
  2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
16
- 3. Run `npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii` to see what actually happens.
17
+ 3. Run `npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii` to see what actually happens. Add `--screenshot 0.5s,end` to save real rendered frames to `screenshots/` and look at them.
17
18
  4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
18
19
  5. Run `npx @ludra/cli dev` so a human can play it in the browser.
19
20
 
@@ -28,3 +29,4 @@ Never call a change done until `ludra check` and `ludra test` both pass.
28
29
  - A jump only triggers on a new press, so put a segment without jump between two jump segments.
29
30
  - The level is done when a test proves `won == true` with `deaths == 0`.
30
31
  - Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
32
+ - For behavior no component provides (chasing, keys, timers, shooting), write a script and attach it with a `script` component. Use `world.random()`, never `Math.random()`, and test what it does through `self.state` and `world.emit`.
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ screenshots/
@@ -8,6 +8,6 @@
8
8
  "test": "ludra test"
9
9
  },
10
10
  "devDependencies": {
11
- "@ludra/cli": "^0.3.1"
11
+ "@ludra/cli": "^0.5.0"
12
12
  }
13
13
  }