@ludra/cli 0.7.0 → 0.8.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.
Files changed (37) hide show
  1. package/dist/commands/check.d.ts +1 -0
  2. package/dist/commands/check.js +56 -17
  3. package/dist/commands/dev.js +7 -2
  4. package/dist/commands/play.d.ts +1 -0
  5. package/dist/commands/play.js +9 -3
  6. package/dist/commands/review.d.ts +22 -0
  7. package/dist/commands/review.js +183 -0
  8. package/dist/commands/test.d.ts +1 -0
  9. package/dist/commands/test.js +20 -5
  10. package/dist/headless.d.ts +2 -4
  11. package/dist/headless.js +18 -3
  12. package/dist/index.js +10 -2
  13. package/dist/project.d.ts +18 -5
  14. package/dist/project.js +59 -20
  15. package/dist/server.js +16 -9
  16. package/package.json +2 -2
  17. package/templates/breakout/CLAUDE.md +3 -2
  18. package/templates/breakout/package.json +1 -1
  19. package/templates/breakout/scenes/main.scene.json +7 -2
  20. package/templates/breakout/scripts/ball.ts +1 -2
  21. package/templates/breakout/scripts/rules.ts +24 -5
  22. package/templates/breakout/tests/gameplay.test.json +1 -1
  23. package/templates/platformer/CLAUDE.md +11 -7
  24. package/templates/platformer/assets/lantern.svg +9 -0
  25. package/templates/platformer/package.json +1 -1
  26. package/templates/platformer/prefabs/coin.prefab.json +8 -0
  27. package/templates/platformer/prefabs/slime.prefab.json +9 -0
  28. package/templates/platformer/scenes/level-2.scene.json +413 -0
  29. package/templates/platformer/scenes/main.scene.json +356 -59
  30. package/templates/platformer/scripts/backdrop.ts +75 -0
  31. package/templates/platformer/scripts/hud.ts +111 -0
  32. package/templates/platformer/scripts/lantern.ts +35 -0
  33. package/templates/platformer/shaders/glow.frag +29 -0
  34. package/templates/platformer/tests/gameplay.test.json +45 -7
  35. package/templates/platformer/tests/level-2.test.json +49 -0
  36. package/templates/topdown/CLAUDE.md +3 -2
  37. package/templates/topdown/package.json +1 -1
package/dist/project.js CHANGED
@@ -35,43 +35,82 @@ export function loadProject(dir) {
35
35
  const entryPath = join(root, config.entry);
36
36
  return { dir: root, config, entryPath, entryLabel: relative(root, entryPath).replaceAll("\\", "/") };
37
37
  }
38
- /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
39
- export function readScene(project) {
40
- if (!existsSync(project.entryPath)) {
38
+ /** Every scene in the project: the entry scene, then every .scene.json file under scenes/. */
39
+ export function listScenes(project) {
40
+ const inFolder = listFiles(project.dir, "scenes").filter((path) => path.endsWith(".scene.json"));
41
+ return [...new Set([project.entryLabel, ...inFolder])];
42
+ }
43
+ /** Prefab files (prefabs/<name>.prefab.json) by name, plus problems reading them. */
44
+ export function readPrefabs(project) {
45
+ const prefabs = {};
46
+ const diagnostics = [];
47
+ for (const path of listFiles(project.dir, "prefabs").filter((p) => p.endsWith(".prefab.json"))) {
48
+ const name = path.slice(path.lastIndexOf("/") + 1, -".prefab.json".length);
49
+ try {
50
+ prefabs[name] = JSON.parse(readFileSync(join(project.dir, path), "utf8"));
51
+ }
52
+ catch (err) {
53
+ diagnostics.push({ severity: "error", code: "E_JSON_SYNTAX", path, message: `Invalid JSON in ${path}: ${err.message}` });
54
+ }
55
+ }
56
+ return { prefabs, diagnostics };
57
+ }
58
+ /**
59
+ * Reads and validates a scene (the entry scene by default), with the project's
60
+ * prefab files merged in. JSON syntax errors become diagnostics too.
61
+ */
62
+ export function readScene(project, scenePath = project.entryLabel) {
63
+ const file = resolve(project.dir, scenePath);
64
+ const label = relative(project.dir, file).replaceAll("\\", "/");
65
+ if (!isInside(project.dir, file) || !existsSync(file)) {
66
+ const scenes = listScenes(project).filter((s) => existsSync(join(project.dir, s)));
41
67
  return {
68
+ label,
42
69
  diagnostics: [
43
70
  {
44
71
  severity: "error",
45
72
  code: "E_SCENE_MISSING",
46
73
  path: "$",
47
- message: `Entry scene ${project.entryLabel} does not exist.`,
48
- fix: `Create it, or point "entry" in ludra.json at an existing scene.`,
74
+ message: `Scene ${label} does not exist.`,
75
+ fix: label === project.entryLabel
76
+ ? `Create it, or point "entry" in ludra.json at an existing scene.`
77
+ : `Create it${scenes.length > 0 ? `, or use one of: ${scenes.join(", ")}` : ""}.`,
49
78
  },
50
79
  ],
51
80
  };
52
81
  }
53
82
  let raw;
54
83
  try {
55
- raw = JSON.parse(readFileSync(project.entryPath, "utf8"));
84
+ raw = JSON.parse(readFileSync(file, "utf8"));
56
85
  }
57
86
  catch (err) {
58
87
  return {
88
+ label,
59
89
  diagnostics: [{ severity: "error", code: "E_JSON_SYNTAX", path: "$", message: `Invalid JSON: ${err.message}` }],
60
90
  };
61
91
  }
92
+ // Prefab files are shared by every scene; a scene's own "prefabs" win over files of the same name.
93
+ const files = readPrefabs(project);
94
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(files.prefabs).length > 0) {
95
+ const own = raw.prefabs;
96
+ raw = { ...raw, prefabs: { ...files.prefabs, ...(own && typeof own === "object" ? own : {}) } };
97
+ }
62
98
  const { scene, diagnostics } = validateScene(raw);
99
+ diagnostics.unshift(...files.diagnostics);
63
100
  if (!scene)
64
- return { diagnostics };
101
+ return { label, diagnostics };
65
102
  const missing = missingAssets(project, scene);
66
- return { scene: missing.length === 0 ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
103
+ const ok = missing.length === 0 && !files.diagnostics.some((d) => d.severity === "error");
104
+ return { label, scene: ok ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
67
105
  }
68
- const EXAMPLE_FILE = { image: "player.svg", audio: "jump.wav", script: "enemy.ts", font: "pixel.woff2", shader: "crt.frag" };
106
+ const EXAMPLE_FILE = { image: "player.svg", audio: "jump.wav", script: "enemy.ts", font: "pixel.woff2", shader: "crt.frag", scene: "level-2.scene.json" };
107
+ const FOLDER = { image: "assets", audio: "assets", script: "scripts", font: "assets", shader: "shaders", scene: "scenes" };
69
108
  /** Files the scene references that are not in the project. */
70
109
  function missingAssets(project, scene) {
71
110
  return collectAssetPaths(scene)
72
111
  .filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
73
112
  .map((ref) => {
74
- const folder = ref.kind === "script" ? "scripts" : ref.kind === "shader" ? "shaders" : "assets";
113
+ const folder = FOLDER[ref.kind];
75
114
  const available = listFiles(project.dir, folder);
76
115
  return {
77
116
  severity: "error",
@@ -104,10 +143,10 @@ export function scriptPaths(project, scene) {
104
143
  * Reads the scene and imports its scripts. Node runs TypeScript scripts
105
144
  * directly (type stripping), so no build step is needed.
106
145
  */
107
- export async function loadGame(project) {
108
- const { scene, diagnostics } = readScene(project);
146
+ export async function loadGame(project, scenePath) {
147
+ const { scene, diagnostics, label } = readScene(project, scenePath);
109
148
  if (!scene)
110
- return { diagnostics };
149
+ return { diagnostics, label };
111
150
  const scripts = new Map();
112
151
  for (const src of scriptPaths(project, scene)) {
113
152
  const file = resolve(project.dir, src);
@@ -138,7 +177,7 @@ export async function loadGame(project) {
138
177
  scripts.set(src, module.default);
139
178
  }
140
179
  const ok = !diagnostics.some((d) => d.severity === "error");
141
- return { game: ok ? { scene, scripts } : undefined, diagnostics };
180
+ return { game: ok ? { scene, scripts } : undefined, diagnostics, label };
142
181
  }
143
182
  /**
144
183
  * Node's syntax errors carry the location only in the stack: "file:///...:LINE", the source
@@ -156,10 +195,10 @@ function describeLoadError(err, src) {
156
195
  return { message: `${src}:${line} failed to load: ${error.message}`, fix: `Line ${line}:\n ${code}\n ${caret}` };
157
196
  }
158
197
  /** Like `loadGame`, but stops with the diagnostics when anything is wrong. */
159
- export async function requireGame(project) {
160
- const { game, diagnostics } = await loadGame(project);
198
+ export async function requireGame(project, scenePath) {
199
+ const { game, diagnostics, label } = await loadGame(project, scenePath);
161
200
  if (!game) {
162
- throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
201
+ throw new CliError(`${formatDiagnostics(diagnostics, label)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
163
202
  }
164
203
  return game;
165
204
  }
@@ -168,10 +207,10 @@ export function isInside(dir, path) {
168
207
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
169
208
  }
170
209
  /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
171
- export function requireScene(project) {
172
- const { scene, diagnostics } = readScene(project);
210
+ export function requireScene(project, scenePath) {
211
+ const { scene, diagnostics, label } = readScene(project, scenePath);
173
212
  if (!scene) {
174
- throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
213
+ throw new CliError(`${formatDiagnostics(diagnostics, label)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
175
214
  }
176
215
  return scene;
177
216
  }
package/dist/server.js CHANGED
@@ -1,10 +1,10 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer } from "node:http";
3
3
  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, scriptPaths } from "./project.js";
7
+ import { isInside, listScenes, readScene, scriptPaths } from "./project.js";
8
8
  export const PROJECT_PREFIX = "/__ludra/project/";
9
9
  export const SCRIPT_PREFIX = "/__ludra/script/";
10
10
  /** An empty same-origin page that headless capture loads before importing the engine. */
@@ -48,7 +48,8 @@ export function createGameServer(project, indexHtml) {
48
48
  res.end('<!doctype html><html><head><meta charset="utf-8"><title>Ludra</title></head><body style="margin:0"></body></html>');
49
49
  }
50
50
  else if (url.pathname === "/__ludra/scene") {
51
- const { scene, diagnostics } = readScene(project);
51
+ // ?path= picks another scene, for goals with "next" and world.goto.
52
+ const { scene, diagnostics } = readScene(project, url.searchParams.get("path") ?? undefined);
52
53
  res.writeHead(200, { "content-type": "application/json" });
53
54
  res.end(JSON.stringify({ ok: !!scene, scene, diagnostics }));
54
55
  }
@@ -100,17 +101,23 @@ export function createGameServer(project, indexHtml) {
100
101
  });
101
102
  return { server, browserModule };
102
103
  }
103
- /** Changes whenever the scene or any file it references changes, so the page knows to reload. */
104
+ /** Changes whenever a scene, prefab or any file they reference changes, so the page knows to reload. */
104
105
  function version(project) {
105
106
  if (!existsSync(project.entryPath))
106
107
  return "missing";
107
- const files = [project.entryPath];
108
- const { scene } = readScene(project);
109
- if (scene) {
108
+ const files = [];
109
+ for (const path of listScenes(project)) {
110
+ files.push(resolve(project.dir, path));
111
+ const { scene } = readScene(project, path);
112
+ if (!scene)
113
+ continue;
110
114
  files.push(...collectAssetPaths(scene).map((ref) => resolve(project.dir, ref.path)));
111
- files.push(...scriptPaths(project, scene).map((path) => resolve(project.dir, path)));
115
+ files.push(...scriptPaths(project, scene).map((p) => resolve(project.dir, p)));
112
116
  }
113
- return files.map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
117
+ const prefabs = join(project.dir, "prefabs");
118
+ if (existsSync(prefabs))
119
+ files.push(prefabs, ...readdirSync(prefabs).map((name) => join(prefabs, name)));
120
+ return [...new Set(files)].map((file) => (existsSync(file) ? statSync(file).mtimeMs : 0)).join(":");
114
121
  }
115
122
  function notFound(res) {
116
123
  res.writeHead(404, { "content-type": "text/plain" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ludra/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {
@@ -35,7 +35,7 @@
35
35
  "blocks"
36
36
  ],
37
37
  "dependencies": {
38
- "@ludra/core": "0.7.0"
38
+ "@ludra/core": "0.8.0"
39
39
  },
40
40
  "keywords": [
41
41
  "game-engine",
@@ -21,9 +21,10 @@ A breakout game built with Ludra, a game engine designed for AI agents.
21
21
  2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
22
22
  3. Run `npx @ludra/cli play --watch ball,paddle --ascii` to see what actually happens. Add `--screenshot 1s,end` to save real rendered frames to `screenshots/` and look at them.
23
23
  4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
24
- 5. Run `npx @ludra/cli dev` so a human can play it in the browser.
24
+ 5. Run `npx @ludra/cli review` and fix every issue it lists: it catches what makes a game feel unfinished (untested mechanics, silent events, the default HUD, flat looks).
25
+ 6. Run `npx @ludra/cli dev` so a human can play it in the browser.
25
26
 
26
- Never call a change done until `ludra check` and `ludra test` both pass.
27
+ Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
27
28
 
28
29
  ## Rules of thumb
29
30
 
@@ -8,6 +8,6 @@
8
8
  "test": "ludra test"
9
9
  },
10
10
  "devDependencies": {
11
- "@ludra/cli": "^0.7.0"
11
+ "@ludra/cli": "^0.8.0"
12
12
  }
13
13
  }
@@ -4,7 +4,8 @@
4
4
  "width": 960,
5
5
  "height": 540,
6
6
  "gravity": 0,
7
- "background": "#05001a"
7
+ "background": "#05001a",
8
+ "lives": 3
8
9
  },
9
10
  "fonts": {
10
11
  "Orbitron": "google"
@@ -38,10 +39,14 @@
38
39
  "launch": {
39
40
  "sound": "jump"
40
41
  },
41
- "ball-lost": {
42
+ "die": {
42
43
  "sound": "explosion",
43
44
  "shake": 10
44
45
  },
46
+ "gameover": {
47
+ "sound": "explosion",
48
+ "shake": 14
49
+ },
45
50
  "win": {
46
51
  "sound": "powerup",
47
52
  "shake": 4,
@@ -12,7 +12,7 @@ interface Params {
12
12
  /**
13
13
  * Rests on the paddle, launches, and keeps a constant speed (bounce: 1 does
14
14
  * the reflecting). Falling past the bottom costs a life: the paddle "dies"
15
- * and the ball goes back onto it.
15
+ * (a die event, or game over on the last ball) and the ball goes back onto it.
16
16
  */
17
17
  export default {
18
18
  start({ self, world, params }) {
@@ -39,7 +39,6 @@ export default {
39
39
  }
40
40
 
41
41
  if (self.y > world.height + 30) {
42
- world.emit("ball-lost");
43
42
  world.kill(paddle);
44
43
  self.state.stuck = true;
45
44
  self.state.launchAt = world.time + launchDelay;
@@ -60,7 +60,7 @@ export default {
60
60
  const left = (self.state.bricksLeft as number) ?? total;
61
61
 
62
62
  // A red flash from the edges when the ball is lost.
63
- const lost = recent("ball-lost", 0.7).at(-1);
63
+ const lost = recent("die", 0.7).at(-1);
64
64
  if (lost) {
65
65
  const strength = 1 - (time - lost.time) / 0.7;
66
66
  const edge = g.createRadialGradient(view.width / 2, view.height / 2, view.height * 0.3, view.width / 2, view.height / 2, view.width * 0.7);
@@ -92,11 +92,16 @@ export default {
92
92
  }
93
93
  g.shadowBlur = 0;
94
94
 
95
- if (world.deaths > 0) {
96
- g.font = `700 11px ${FONT}`;
97
- g.fillStyle = "rgba(255, 120, 140, 0.8)";
98
- g.fillText(`BALLS LOST ${world.deaths}`, view.width - 36, 84);
95
+ // Balls left, as glowing dots under the meter.
96
+ for (let i = 0; i < (world.lives ?? 0); i++) {
97
+ g.fillStyle = "#ffd60a";
98
+ g.shadowColor = "#ffd60a";
99
+ g.shadowBlur = 10;
100
+ g.beginPath();
101
+ g.arc(view.width - 42 - i * 18, 84, 5, 0, Math.PI * 2);
102
+ g.fill();
99
103
  }
104
+ g.shadowBlur = 0;
100
105
 
101
106
  g.textAlign = "center";
102
107
  if (time < 2.5 && !world.won) {
@@ -124,5 +129,19 @@ export default {
124
129
  g.fillText("PRESS R TO PLAY AGAIN", view.width / 2, view.height / 2 + 84);
125
130
  g.globalAlpha = 1;
126
131
  }
132
+
133
+ if (world.lost) {
134
+ const over = recent("gameover", 1e9).at(-1);
135
+ const since = over ? time - over.time : 0;
136
+ g.fillStyle = `rgba(20, 0, 10, ${Math.min(0.75, since * 1.5)})`;
137
+ g.fillRect(0, 0, view.width, view.height);
138
+ g.globalAlpha = Math.min(1, since * 2);
139
+ neonText(g, "GAME OVER", view.width / 2, view.height / 2 - 6, 64, "#ff2a6d");
140
+ neonText(g, `${left} BRICK${left === 1 ? "" : "S"} TO GO · SCORE ${world.score}`, view.width / 2, view.height / 2 + 44, 20, "#00f0ff", 700);
141
+ g.font = `700 12px ${FONT}`;
142
+ g.fillStyle = "rgba(255, 255, 255, 0.6)";
143
+ g.fillText("PRESS R TO TRY AGAIN", view.width / 2, view.height / 2 + 84);
144
+ g.globalAlpha = 1;
145
+ }
127
146
  },
128
147
  } satisfies Script;
@@ -19,7 +19,7 @@
19
19
  {
20
20
  "name": "missing the ball costs a life and puts the ball back on the paddle",
21
21
  "duration": "3s",
22
- "assert": ["events.ball-lost == 1", "deaths == 1", "ball.state.stuck == true"]
22
+ "assert": ["events.die == 1", "lives == 2", "ball.state.stuck == true"]
23
23
  },
24
24
  {
25
25
  "name": "the wall can be cleared without losing a life",
@@ -1,14 +1,16 @@
1
1
  # {{name}}
2
2
 
3
- A game built with Ludra, a game engine designed for AI agents.
3
+ A platformer built with Ludra, a game engine designed for AI agents: two levels, Night Meadow and Moonlit Cliffs.
4
4
 
5
5
  ## Layout
6
6
 
7
7
  - `ludra.json`: project config (name, entry scene, window size)
8
- - `scenes/main.scene.json`: the level. Entities are made of components; `effects` adds particles, sound and shake to game events.
8
+ - `scenes/main.scene.json`: level 1 (the entry scene). Its flag's `next` leads to `scenes/level-2.scene.json`. Entities are made of components; `effects` adds particles, sound and shake to game events.
9
+ - `prefabs/`: `coin` and `slime`, reused by both levels. An entity says `"prefab": "slime"` and lists only what differs.
9
10
  - `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.
11
- - `tests/*.test.json`: gameplay tests, run headlessly
11
+ - `scripts/`: `backdrop.ts` (the night sky and parallax hills), `lantern.ts` (lights a reached checkpoint), `hud.ts` (hearts, points, title card, end screens). Run `npx @ludra/cli components script` for the API.
12
+ - `shaders/glow.frag`: the finishing shader (bloom, grade, vignette).
13
+ - `tests/*.test.json`: gameplay tests, run headlessly. Tests for level 2 say `"scene": "scenes/level-2.scene.json"`.
12
14
 
13
15
  ## Loop
14
16
 
@@ -16,9 +18,10 @@ A game built with Ludra, a game engine designed for AI agents.
16
18
  2. Run `npx @ludra/cli check` after every edit. Each problem names the JSON path and a fix.
17
19
  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.
18
20
  4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
19
- 5. Run `npx @ludra/cli dev` so a human can play it in the browser.
21
+ 5. Run `npx @ludra/cli review` and fix every issue it lists: it catches what makes a game feel unfinished (untested mechanics, silent events, the default HUD, flat looks).
22
+ 6. Run `npx @ludra/cli dev` so a human can play it in the browser.
20
23
 
21
- Never call a change done until `ludra check` and `ludra test` both pass.
24
+ Never call a change done until `ludra check`, `ludra test` and `ludra review` are all clean.
22
25
 
23
26
  ## Rules of thumb
24
27
 
@@ -27,7 +30,8 @@ Never call a change done until `ludra check` and `ludra test` both pass.
27
30
  - Collectibles, hazards and goals need `"sensor": true` on their body.
28
31
  - Moving platforms and enemies are kinematic bodies with a `patrol`. Players standing on a moving platform are carried.
29
32
  - A jump only triggers on a new press, so put a segment without jump between two jump segments.
30
- - The level is done when a test proves `won == true` with `deaths == 0`.
33
+ - The game has 3 lives (`world.lives`); the lantern is a `checkpoint`, placed where the player already stands before a hard part.
34
+ - A level is done when a test proves `won == true` with `deaths == 0`. Add a level: copy a scene, point the last flag's `next` at it, and give it its own tests.
31
35
  - For more than sprites (lighting, glow, your own HUD, fonts, shaders), scripts can have `draw` and `drawUI` hooks with the real Canvas 2D context, and the scene can set `"fonts"` and `"postfx"`. See `npx @ludra/cli components script`.
32
36
  - Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
33
37
  - 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,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="64" viewBox="0 0 24 64">
2
+ <!-- A checkpoint: a wooden post with an unlit lantern hanging from it. lantern.ts lights it. -->
3
+ <rect x="10" y="6" width="4" height="58" rx="1.5" fill="#6b4f3a"/>
4
+ <rect x="10" y="6" width="2" height="58" fill="#80624a"/>
5
+ <rect x="4" y="6" width="16" height="3" rx="1.5" fill="#5c4332"/>
6
+ <path d="M6 9 v4" stroke="#cfd8ff" stroke-width="1"/>
7
+ <rect x="2" y="13" width="8" height="12" rx="2" fill="#2a3150" stroke="#cfd8ff" stroke-width="1.2"/>
8
+ <rect x="4" y="11" width="4" height="2" rx="1" fill="#cfd8ff"/>
9
+ </svg>
@@ -8,6 +8,6 @@
8
8
  "test": "ludra test"
9
9
  },
10
10
  "devDependencies": {
11
- "@ludra/cli": "^0.7.0"
11
+ "@ludra/cli": "^0.8.0"
12
12
  }
13
13
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "A spinning gold coin worth 1 point. Place it with just a transform.",
3
+ "components": {
4
+ "body": { "type": "static", "width": 20, "height": 20, "sensor": true },
5
+ "sprite": { "shape": "circle", "color": "#ffd166", "image": "assets/coin.svg", "frameWidth": 20, "idle": "0-3", "layer": 1 },
6
+ "collectible": { "score": 1 }
7
+ }
8
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "description": "A purple slime that walks back and forth; stomp it for a point. Override patrol to change its range and pace.",
3
+ "components": {
4
+ "body": { "type": "kinematic", "width": 32, "height": 26, "sensor": true },
5
+ "sprite": { "color": "#c77dff", "image": "assets/slime.svg", "frameWidth": 32, "idle": "0", "run": "0-1", "fps": 4, "layer": 2 },
6
+ "patrol": { "axis": "x", "distance": 200, "speed": 70 },
7
+ "hazard": { "stompable": true, "score": 1 }
8
+ }
9
+ }