@ludra/cli 0.4.0 → 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,6 +1,6 @@
1
1
  import { paint } from "../format.js";
2
2
  import { CliError, loadProject } from "../project.js";
3
- import { createGameServer, PROJECT_PREFIX } from "../server.js";
3
+ import { createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "../server.js";
4
4
  /** Serves the game for a human to play, and reloads the page when the scene or its assets change. */
5
5
  export function dev(dir, options) {
6
6
  const project = loadProject(dir);
@@ -43,7 +43,8 @@ import { startGame } from "${browserModule}";
43
43
 
44
44
  const data = await (await fetch("/__ludra/scene")).json();
45
45
  if (data.ok) {
46
- 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 });
47
48
  } else {
48
49
  document.getElementById("game").hidden = true;
49
50
  const errors = document.getElementById("errors");
@@ -1,17 +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
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join, relative, resolve } from "node:path";
4
4
  import { fixed, pad, padStart, paint } from "../format.js";
5
5
  import { captureScreenshots } from "../headless.js";
6
- import { CliError, loadProject, requireScene } from "../project.js";
6
+ import { CliError, loadProject, requireGame } from "../project.js";
7
7
  const MAX_EVENTS_SHOWN = 40;
8
8
  export async function play(dir, options) {
9
9
  const project = loadProject(dir);
10
- const scene = requireScene(project);
10
+ const { scene, scripts } = await requireGame(project);
11
11
  const script = parse(() => InputScript.parse(options.inputs ?? ""));
12
12
  const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
13
13
  const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
14
- const world = new World(scene);
14
+ const world = scriptErrors(() => new World(scene, { scripts }));
15
15
  const watch = options.watch
16
16
  ? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
17
17
  : world.entities.filter((e) => e.platformer).map((e) => e.id);
@@ -21,13 +21,13 @@ export async function play(dir, options) {
21
21
  }
22
22
  }
23
23
  const rows = [];
24
- simulate(world, script, frames, (w) => {
24
+ scriptErrors(() => simulate(world, script, frames, (w) => {
25
25
  if (w.frame % every !== 0 && w.frame !== frames)
26
26
  return;
27
27
  const snapshot = w.snapshot();
28
28
  const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
29
29
  rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
30
- });
30
+ }));
31
31
  const columns = Number(options.columns ?? 80);
32
32
  if (!Number.isInteger(columns) || columns < 10)
33
33
  throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
@@ -96,7 +96,8 @@ function printTimeline(rows, watch) {
96
96
  function printEvents(events) {
97
97
  console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
98
98
  for (const event of events.slice(0, MAX_EVENTS_SHOWN)) {
99
- 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)}`);
100
101
  }
101
102
  if (events.length > MAX_EVENTS_SHOWN)
102
103
  console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
@@ -110,11 +111,24 @@ function describeEvent(event) {
110
111
  case "die":
111
112
  return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
112
113
  case "win":
113
- 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)}`}`;
114
117
  default:
115
118
  return event.entity;
116
119
  }
117
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
+ }
118
132
  /** Turns engine parse errors into user-facing CLI errors. */
119
133
  function parse(fn) {
120
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;
package/dist/headless.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { CliError } from "./project.js";
6
- import { BLANK_PATH, createGameServer, PROJECT_PREFIX } from "./server.js";
6
+ import { BLANK_PATH, createGameServer, PROJECT_PREFIX, SCRIPT_PREFIX } from "./server.js";
7
7
  const TIMEOUT_MS = 60_000;
8
8
  /**
9
9
  * Finds an installed Chromium-based browser: LUDRA_BROWSER if set, otherwise
@@ -78,6 +78,8 @@ export async function captureScreenshots(project, request) {
78
78
  document.body.append(canvas);
79
79
  return captureFrames(canvas, data.scene, {
80
80
  assetBase: ${JSON.stringify(PROJECT_PREFIX)},
81
+ scriptBase: ${JSON.stringify(SCRIPT_PREFIX)},
82
+ scriptPaths: await (await fetch("/__ludra/scripts")).json(),
81
83
  inputs: ${JSON.stringify(request.inputs)},
82
84
  frames: ${JSON.stringify(request.frames)},
83
85
  });
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
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);
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Server } from "node:http";
2
2
  import { type Project } from "./project.ts";
3
3
  export declare const PROJECT_PREFIX = "/__ludra/project/";
4
+ export declare const SCRIPT_PREFIX = "/__ludra/script/";
4
5
  /** An empty same-origin page that headless capture loads before importing the engine. */
5
6
  export declare const BLANK_PATH = "/__ludra/blank";
6
7
  export interface GameServer {
package/dist/server.js CHANGED
@@ -4,8 +4,9 @@ import { stripTypeScriptTypes } from "node:module";
4
4
  import { dirname, extname, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { collectAssetPaths } from "@ludra/core";
7
- import { isInside, readScene } from "./project.js";
7
+ import { isInside, readScene, scriptPaths } from "./project.js";
8
8
  export const PROJECT_PREFIX = "/__ludra/project/";
9
+ export const SCRIPT_PREFIX = "/__ludra/script/";
9
10
  /** An empty same-origin page that headless capture loads before importing the engine. */
10
11
  export const BLANK_PATH = "/__ludra/blank";
11
12
  const MEDIA_TYPES = {
@@ -49,6 +50,22 @@ export function createGameServer(project, indexHtml) {
49
50
  res.writeHead(200, { "content-type": "text/plain" });
50
51
  res.end(version(project));
51
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
+ }
52
69
  else if (url.pathname.startsWith(PROJECT_PREFIX)) {
53
70
  // Only media files inside the project are served, never source or config.
54
71
  const file = resolve(project.dir, decodeURIComponent(url.pathname.slice(PROJECT_PREFIX.length)));
@@ -83,8 +100,10 @@ function version(project) {
83
100
  return "missing";
84
101
  const files = [project.entryPath];
85
102
  const { scene } = readScene(project);
86
- if (scene)
103
+ if (scene) {
87
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
+ }
88
107
  return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
89
108
  }
90
109
  function notFound(res) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/cli",
3
- "version": "0.4.0",
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.4.0"
37
+ "@ludra/core": "0.5.0"
38
38
  },
39
39
  "keywords": [
40
40
  "game-engine",
@@ -7,6 +7,7 @@ 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
@@ -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`.
@@ -8,6 +8,6 @@
8
8
  "test": "ludra test"
9
9
  },
10
10
  "devDependencies": {
11
- "@ludra/cli": "^0.4.0"
11
+ "@ludra/cli": "^0.5.0"
12
12
  }
13
13
  }