@ludra/cli 0.0.0-stage → 0.3.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ludra contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @ludra/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The game engine built for AI agents.** This package provides the `ludra` command.
4
+
5
+ Games are plain JSON, every error comes with a fix, and agents play-test their own games headlessly before a human ever touches them.
6
+
7
+ ```bash
8
+ npx @ludra/cli new my-game
9
+ cd my-game
10
+ npx @ludra/cli check # validate; every problem has a fix
11
+ npx @ludra/cli play --inputs "right:1s, jump:0.4s" --ascii # simulate and see the frame as text
12
+ npx @ludra/cli test # run tests/*.test.json
13
+ npx @ludra/cli dev # play it in the browser
14
+ ```
15
+
16
+ Or install it once and use the short command:
17
+
18
+ ```bash
19
+ npm install -g @ludra/cli
20
+ ludra new my-game
21
+ ```
22
+
23
+ | Command | What it does |
24
+ | --- | --- |
25
+ | `ludra new <folder>` | Create a game from the platformer template (art, effects and tests included) |
26
+ | `ludra check [folder]` | Validate the scene and its assets |
27
+ | `ludra play [folder]` | Deterministic headless run: timeline, events, ASCII map |
28
+ | `ludra test [folder]` | Scripted inputs plus assertions such as `won == true` or `deaths == 0` |
29
+ | `ludra components [name]` | Reference for every component, field and effect |
30
+ | `ludra dev [folder]` | Browser runtime with keyboard controls, sound and live reload |
31
+
32
+ Every command accepts `--json`. Requires Node.js 22.18 or newer.
33
+
34
+ The engine runtime is published separately as [`@ludra/core`](https://www.npmjs.com/package/@ludra/core).
35
+
36
+ Documentation, the Claude Code plugin and examples: **https://github.com/ersingvn/ludra**
37
+
38
+ MIT licensed.
@@ -0,0 +1,3 @@
1
+ export declare function check(dir: string, options: {
2
+ json?: boolean;
3
+ }): number;
@@ -0,0 +1,21 @@
1
+ import { formatDiagnostics, paint } from "../format.js";
2
+ import { loadProject, readScene } from "../project.js";
3
+ export function check(dir, options) {
4
+ const project = loadProject(dir);
5
+ const { scene, diagnostics } = readScene(project);
6
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
7
+ const warnings = diagnostics.length - errors;
8
+ if (options.json) {
9
+ console.log(JSON.stringify({ ok: errors === 0, file: project.entryLabel, errors, warnings, diagnostics }, null, 2));
10
+ return errors > 0 ? 1 : 0;
11
+ }
12
+ if (diagnostics.length > 0)
13
+ console.log(`${formatDiagnostics(diagnostics, project.entryLabel)}\n`);
14
+ if (errors > 0) {
15
+ console.log(paint("red", `✗ ${project.entryLabel}: ${errors} error(s), ${warnings} warning(s)`));
16
+ return 1;
17
+ }
18
+ const count = scene?.entities.length ?? 0;
19
+ console.log(paint("green", `✓ ${project.entryLabel}: ${count} entities, ${warnings === 0 ? "no problems" : `${warnings} warning(s)`}`));
20
+ return 0;
21
+ }
@@ -0,0 +1,3 @@
1
+ export declare function components(name: string | undefined, options: {
2
+ json?: boolean;
3
+ }): number;
@@ -0,0 +1,55 @@
1
+ import { COMPONENT_NAMES, COMPONENTS, EFFECT_FIELDS, EFFECTS_DESCRIPTION, EVENT_TYPES, isComponentName, PARTICLE_FIELDS, SOUND_PRESETS, } from "@ludra/core";
2
+ import { pad, paint } from "../format.js";
3
+ import { CliError } from "../project.js";
4
+ const EFFECTS_EXAMPLE = `"effects": {
5
+ "collect": { "sound": "coin", "particles": { "count": 12, "color": "#ffd166", "speed": 140 } },
6
+ "die": { "sound": "explosion", "shake": 8, "particles": { "count": 24, "color": "#ff5d73" } }
7
+ }`;
8
+ export function components(name, options) {
9
+ if (name !== undefined && name !== "effects" && !isComponentName(name)) {
10
+ throw new CliError(`Unknown component "${name}". Components: ${COMPONENT_NAMES.join(", ")}, plus "effects" for scene effects.`);
11
+ }
12
+ const names = name === undefined ? COMPONENT_NAMES : isComponentName(name) ? [name] : [];
13
+ const showEffects = name === undefined || name === "effects";
14
+ if (options.json) {
15
+ const effects = { description: EFFECTS_DESCRIPTION, events: EVENT_TYPES, soundPresets: SOUND_PRESETS, fields: EFFECT_FIELDS, particles: PARTICLE_FIELDS };
16
+ const output = Object.fromEntries(names.map((n) => [n, COMPONENTS[n]]));
17
+ console.log(JSON.stringify(showEffects ? { ...output, effects } : output, null, 2));
18
+ return 0;
19
+ }
20
+ for (const n of names) {
21
+ const spec = COMPONENTS[n];
22
+ console.log(`${paint("bold", n)} — ${spec.description}`);
23
+ if (spec.requires?.length)
24
+ console.log(paint("dim", ` requires: ${spec.requires.join(", ")}`));
25
+ printFields(spec.fields);
26
+ console.log();
27
+ }
28
+ if (showEffects) {
29
+ console.log(`${paint("bold", "effects")} (scene level, not a component) — ${EFFECTS_DESCRIPTION}`);
30
+ console.log(paint("dim", ` events: ${EVENT_TYPES.join(", ")}`));
31
+ console.log(paint("dim", ` sound presets: ${SOUND_PRESETS.join(", ")}`));
32
+ printFields(EFFECT_FIELDS);
33
+ console.log(` ${pad("particles", 13)} ${pad("object", 34)} ${pad("optional", 18)} ${paint("dim", "A burst at the event position, with these fields:")}`);
34
+ printFields(PARTICLE_FIELDS, " ");
35
+ console.log(paint("dim", ` example:\n ${EFFECTS_EXAMPLE}`));
36
+ console.log();
37
+ }
38
+ return 0;
39
+ }
40
+ function printFields(fields, indent = " ") {
41
+ for (const [field, fieldSpec] of Object.entries(fields)) {
42
+ // Pad before coloring so escape codes don't break the alignment.
43
+ const required = fieldSpec.default === undefined ? paint("yellow", pad("required", 18)) : pad(`default ${JSON.stringify(fieldSpec.default)}`, 18);
44
+ console.log(`${indent}${pad(field, 15 - indent.length)} ${pad(typeLabel(fieldSpec), 34)} ${required} ${paint("dim", fieldSpec.description)}`);
45
+ }
46
+ }
47
+ function typeLabel(spec) {
48
+ if (spec.type === "enum")
49
+ return spec.values.map((v) => `"${v}"`).join(" | ");
50
+ if (spec.type === "number" && spec.min !== undefined)
51
+ return `number ≥ ${spec.min}`;
52
+ if (spec.type === "frames")
53
+ return 'frames ("0", "1-4", "0,2")';
54
+ return spec.type;
55
+ }
@@ -0,0 +1,8 @@
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
+ */
6
+ export declare function dev(dir: string, options: {
7
+ port?: string;
8
+ }): number;
@@ -0,0 +1,146 @@
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 { 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
+ */
14
+ export function dev(dir, options) {
15
+ const project = loadProject(dir);
16
+ const port = Number(options.port ?? 5173);
17
+ if (!Number.isInteger(port) || port <= 0)
18
+ 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
+ });
64
+ server.on("error", (err) => {
65
+ console.error(paint("red", err.code === "EADDRINUSE" ? `Port ${port} is in use. Try --port ${port + 1}.` : err.message));
66
+ process.exit(1);
67
+ });
68
+ server.listen(port, "127.0.0.1", () => {
69
+ console.log(paint("green", `▶ ${project.config.name} is running at http://localhost:${port}`));
70
+ console.log(paint("dim", ` Arrows/WASD to move, Space/Z to jump, M to mute, R to restart.`));
71
+ console.log(paint("dim", ` Editing ${project.entryLabel} or its images and sounds reloads the page. Ctrl+C to stop.`));
72
+ });
73
+ return 0;
74
+ }
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
+ function page(project, browserModule) {
102
+ const { width, height } = project.config.window;
103
+ const title = escapeHtml(project.config.name);
104
+ return `<!doctype html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8">
108
+ <meta name="viewport" content="width=device-width, initial-scale=1">
109
+ <title>${title} · Ludra</title>
110
+ <style>
111
+ html, body { margin: 0; height: 100%; background: #0b0e14; color: #e6e6e6; font-family: system-ui, sans-serif; }
112
+ body { display: grid; place-items: center; }
113
+ canvas { max-width: 100vw; max-height: 100vh; box-shadow: 0 0 0 1px #2a3142; }
114
+ #errors { max-width: 900px; padding: 24px; white-space: pre-wrap; font: 14px/1.5 ui-monospace, monospace; color: #ff9b9b; }
115
+ </style>
116
+ </head>
117
+ <body>
118
+ <canvas id="game" width="${width}" height="${height}"></canvas>
119
+ <pre id="errors" hidden></pre>
120
+ <script type="module">
121
+ import { startGame } from "${browserModule}";
122
+
123
+ const data = await (await fetch("/__ludra/scene")).json();
124
+ if (data.ok) {
125
+ await startGame(document.getElementById("game"), data.scene, { assetBase: "${PROJECT_PREFIX}" });
126
+ } else {
127
+ document.getElementById("game").hidden = true;
128
+ const errors = document.getElementById("errors");
129
+ errors.hidden = false;
130
+ errors.textContent = "The scene has errors (run \`ludra check\`):\\n\\n" +
131
+ data.diagnostics.map((d) => d.severity + " " + d.code + " at " + d.path + "\\n " + d.message + (d.fix ? "\\n fix: " + d.fix : "")).join("\\n\\n");
132
+ }
133
+
134
+ const version = await (await fetch("/__ludra/version")).text();
135
+ setInterval(async () => {
136
+ try {
137
+ if ((await (await fetch("/__ludra/version")).text()) !== version) location.reload();
138
+ } catch {}
139
+ }, 1000);
140
+ </script>
141
+ </body>
142
+ </html>`;
143
+ }
144
+ function escapeHtml(text) {
145
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
146
+ }
@@ -0,0 +1,3 @@
1
+ export declare function create(dir: string | undefined, options: {
2
+ template?: string;
3
+ }): number;
@@ -0,0 +1,42 @@
1
+ import { cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { paint } from "../format.js";
5
+ import { CliError } from "../project.js";
6
+ /** Same relative location from `src/commands` and `dist/commands`. */
7
+ const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
8
+ export function create(dir, options) {
9
+ if (!dir)
10
+ throw new CliError("Usage: ludra new <folder> [--template platformer]");
11
+ const template = options.template ?? "platformer";
12
+ const source = join(TEMPLATES_DIR, template);
13
+ if (!existsSync(source)) {
14
+ throw new CliError(`Unknown template "${template}". Templates: ${readdirSync(TEMPLATES_DIR).join(", ")}.`);
15
+ }
16
+ const target = resolve(dir);
17
+ if (existsSync(target) && readdirSync(target).length > 0) {
18
+ throw new CliError(`${target} already exists and is not empty. Choose a new folder.`);
19
+ }
20
+ const name = basename(target);
21
+ cpSync(source, target, { recursive: true });
22
+ for (const file of listFiles(target)) {
23
+ const text = readFileSync(file, "utf8");
24
+ if (text.includes("{{name}}"))
25
+ writeFileSync(file, text.replaceAll("{{name}}", name));
26
+ }
27
+ const shown = relative(process.cwd(), target) || ".";
28
+ console.log(paint("green", `✓ Created ${name} from the "${template}" template in ${shown}`));
29
+ console.log(`
30
+ Next steps:
31
+ npx @ludra/cli check ${shown} validate the scene
32
+ npx @ludra/cli play ${shown} --ascii simulate it headlessly and see the final frame
33
+ npx @ludra/cli test ${shown} run tests/*.test.json
34
+ npx @ludra/cli dev ${shown} play it in the browser`);
35
+ return 0;
36
+ }
37
+ function listFiles(dir) {
38
+ return readdirSync(dir).flatMap((entry) => {
39
+ const path = join(dir, entry);
40
+ return statSync(path).isDirectory() ? listFiles(path) : [path];
41
+ });
42
+ }
@@ -0,0 +1,10 @@
1
+ export interface PlayOptions {
2
+ inputs?: string;
3
+ duration?: string;
4
+ watch?: string;
5
+ every?: string;
6
+ ascii?: boolean;
7
+ columns?: string;
8
+ json?: boolean;
9
+ }
10
+ export declare function play(dir: string, options: PlayOptions): number;
@@ -0,0 +1,92 @@
1
+ import { defaultDuration, InputScript, parseDuration, renderAscii, secondsToFrames, simulate, World, } from "@ludra/core";
2
+ import { fixed, pad, padStart, paint } from "../format.js";
3
+ import { CliError, loadProject, requireScene } from "../project.js";
4
+ const MAX_EVENTS_SHOWN = 40;
5
+ export function play(dir, options) {
6
+ const project = loadProject(dir);
7
+ const scene = requireScene(project);
8
+ const script = parse(() => InputScript.parse(options.inputs ?? ""));
9
+ const frames = secondsToFrames(options.duration ? parse(() => parseDuration(options.duration)) : defaultDuration(script));
10
+ const every = Math.max(1, secondsToFrames(parse(() => parseDuration(options.every ?? "0.25s"))));
11
+ const world = new World(scene);
12
+ const watch = options.watch
13
+ ? options.watch.split(",").map((id) => id.trim()).filter(Boolean)
14
+ : world.entities.filter((e) => e.platformer).map((e) => e.id);
15
+ for (const id of watch) {
16
+ if (!world.get(id)) {
17
+ throw new CliError(`--watch: no entity "${id}". Entities: ${world.entities.map((e) => e.id).join(", ")}.`);
18
+ }
19
+ }
20
+ const rows = [];
21
+ simulate(world, script, frames, (w) => {
22
+ if (w.frame % every !== 0 && w.frame !== frames)
23
+ return;
24
+ const snapshot = w.snapshot();
25
+ const input = w.frame === 0 ? [] : [...script.at(w.frame - 1)];
26
+ rows.push({ time: w.time, input, entities: Object.fromEntries(watch.map((id) => [id, snapshot.entities[id] ?? null])) });
27
+ });
28
+ const columns = Number(options.columns ?? 80);
29
+ if (!Number.isInteger(columns) || columns < 10)
30
+ throw new CliError(`--columns must be a whole number of at least 10, got "${options.columns}".`);
31
+ const ascii = options.ascii ? renderAscii(world, { columns }) : undefined;
32
+ 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));
34
+ return 0;
35
+ }
36
+ console.log(paint("bold", `▶ ${project.config.name}: simulated ${fixed(frames / 60, 2)}s (${frames} frames)`) +
37
+ paint("dim", ` inputs: ${options.inputs?.trim() || "none"}`));
38
+ if (watch.length > 0)
39
+ printTimeline(rows, watch);
40
+ printEvents(world.events);
41
+ const outcome = world.won ? paint("green", "level complete") : "level not complete";
42
+ console.log(`\n${paint("bold", "Final:")} ${outcome} · score ${world.score} · deaths ${world.deaths} · ${world.entities.length} entities`);
43
+ if (ascii)
44
+ console.log(`\n${ascii}`);
45
+ return 0;
46
+ }
47
+ function printTimeline(rows, watch) {
48
+ const idWidth = Math.max(6, ...watch.map((id) => id.length));
49
+ 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`));
50
+ for (const row of rows) {
51
+ watch.forEach((id, i) => {
52
+ const e = row.entities[id];
53
+ const time = i === 0 ? pad(`${fixed(row.time, 2)}s`, 7) : " ".repeat(7);
54
+ const input = i === 0 ? pad(row.input.join("+") || "-", 14) : " ".repeat(14);
55
+ const values = e
56
+ ? `${padStart(fixed(e.x), 8)} ${padStart(fixed(e.y), 8)} ${padStart(fixed(e.vx), 8)} ${padStart(fixed(e.vy), 8)} ${e.grounded ? "yes" : "no"}`
57
+ : paint("dim", "(removed)");
58
+ console.log(` ${time} ${input} ${pad(id, idWidth)} ${values}`);
59
+ });
60
+ }
61
+ }
62
+ function printEvents(events) {
63
+ console.log(`\n${paint("bold", "Events")}${events.length === 0 ? paint("dim", " (none)") : ""}`);
64
+ 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)}`);
66
+ }
67
+ if (events.length > MAX_EVENTS_SHOWN)
68
+ console.log(paint("dim", ` … ${events.length - MAX_EVENTS_SHOWN} more (use --json for all)`));
69
+ }
70
+ function describeEvent(event) {
71
+ switch (event.type) {
72
+ case "collect":
73
+ return `${event.entity} → ${event.item} (+${event.score})`;
74
+ case "stomp":
75
+ return `${event.entity} stomped ${event.enemy}${event.score ? ` (+${event.score})` : ""}`;
76
+ case "die":
77
+ return `${event.entity} ${event.cause === "fall" ? "fell out of the world" : `hit ${event.cause}`}, respawned`;
78
+ case "win":
79
+ return `${event.entity} reached ${event.goal}`;
80
+ default:
81
+ return event.entity;
82
+ }
83
+ }
84
+ /** Turns engine parse errors into user-facing CLI errors. */
85
+ function parse(fn) {
86
+ try {
87
+ return fn();
88
+ }
89
+ catch (err) {
90
+ throw new CliError(err.message);
91
+ }
92
+ }
@@ -0,0 +1,3 @@
1
+ export declare function test(dir: string, options: {
2
+ json?: boolean;
3
+ }): number;
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { runTest } from "@ludra/core";
4
+ import { paint } from "../format.js";
5
+ import { loadProject, requireScene } from "../project.js";
6
+ export function test(dir, options) {
7
+ const project = loadProject(dir);
8
+ const scene = requireScene(project);
9
+ const testsDir = join(project.dir, "tests");
10
+ const files = existsSync(testsDir) ? readdirSync(testsDir).filter((f) => f.endsWith(".test.json")).sort() : [];
11
+ if (files.length === 0) {
12
+ const message = 'No tests found. Add tests/<name>.test.json with { "tests": [{ "name": "...", "inputs": "right:1s", "assert": ["player.x > 300"] }] }.';
13
+ if (options.json)
14
+ console.log(JSON.stringify({ ok: false, error: message, files: [] }, null, 2));
15
+ else
16
+ console.log(paint("yellow", message));
17
+ return 1;
18
+ }
19
+ const results = files.map((file) => {
20
+ let cases;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(join(testsDir, file), "utf8"));
23
+ cases = parsed.tests;
24
+ if (!Array.isArray(cases))
25
+ throw new Error('expected { "tests": [...] }');
26
+ }
27
+ catch (err) {
28
+ return { file, error: err.message, tests: [] };
29
+ }
30
+ return { file, tests: cases.map((c) => runTest(scene, c)) };
31
+ });
32
+ const all = results.flatMap((r) => r.tests);
33
+ const failed = all.filter((t) => !t.pass).length + results.filter((r) => r.error).length;
34
+ if (options.json) {
35
+ console.log(JSON.stringify({ ok: failed === 0, passed: all.length - failed, failed, files: results }, null, 2));
36
+ return failed > 0 ? 1 : 0;
37
+ }
38
+ for (const result of results) {
39
+ console.log(paint("bold", result.file));
40
+ if (result.error)
41
+ console.log(paint("red", ` ✗ could not read file: ${result.error}`));
42
+ for (const t of result.tests) {
43
+ console.log(t.pass ? paint("green", ` ✓ ${t.name}`) : paint("red", ` ✗ ${t.name}`));
44
+ if (t.error)
45
+ console.log(` ${t.error}`);
46
+ for (const r of t.results) {
47
+ if (!r.pass)
48
+ console.log(` ${paint("red", "✗")} ${r.source} ${paint("dim", "→")} ${r.message}`);
49
+ }
50
+ }
51
+ }
52
+ const summary = `\n${all.length - failed} passed, ${failed} failed`;
53
+ console.log(failed > 0 ? paint("red", summary) : paint("green", summary));
54
+ return failed > 0 ? 1 : 0;
55
+ }
@@ -0,0 +1,11 @@
1
+ import { styleText } from "node:util";
2
+ import type { Diagnostic } from "@ludra/core";
3
+ type Style = Parameters<typeof styleText>[0];
4
+ /** Colors only when writing to a terminal; agents reading piped output get plain text. */
5
+ export declare function paint(style: Style, text: string): string;
6
+ export declare function formatDiagnostic(d: Diagnostic, file: string): string;
7
+ export declare function formatDiagnostics(diagnostics: readonly Diagnostic[], file: string): string;
8
+ export declare function fixed(n: number, digits?: number): string;
9
+ export declare function pad(text: string, width: number): string;
10
+ export declare function padStart(text: string, width: number): string;
11
+ export {};
package/dist/format.js ADDED
@@ -0,0 +1,24 @@
1
+ import { styleText } from "node:util";
2
+ /** Colors only when writing to a terminal; agents reading piped output get plain text. */
3
+ export function paint(style, text) {
4
+ return styleText(style, text, { stream: process.stdout });
5
+ }
6
+ export function formatDiagnostic(d, file) {
7
+ const icon = d.severity === "error" ? paint("red", "✗ error") : paint("yellow", "! warning");
8
+ const lines = [`${icon} ${d.code} ${paint("dim", `${file} → ${d.path}`)}`, ` ${d.message}`];
9
+ if (d.fix)
10
+ lines.push(` ${paint("cyan", "fix:")} ${d.fix}`);
11
+ return lines.join("\n");
12
+ }
13
+ export function formatDiagnostics(diagnostics, file) {
14
+ return diagnostics.map((d) => formatDiagnostic(d, file)).join("\n\n");
15
+ }
16
+ export function fixed(n, digits = 1) {
17
+ return n.toFixed(digits);
18
+ }
19
+ export function pad(text, width) {
20
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
21
+ }
22
+ export function padStart(text, width) {
23
+ return text.length >= width ? text : " ".repeat(width - text.length) + text;
24
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { check } from "./commands/check.js";
4
+ import { components } from "./commands/components.js";
5
+ import { dev } from "./commands/dev.js";
6
+ import { create } from "./commands/new.js";
7
+ import { play } from "./commands/play.js";
8
+ import { test } from "./commands/test.js";
9
+ import { paint } from "./format.js";
10
+ import { CliError } from "./project.js";
11
+ const HELP = `ludra: the game engine built for AI agents
12
+
13
+ Usage
14
+ ludra new <folder> [--template platformer] Create a game project
15
+ ludra check [folder] [--json] Validate the scene; every problem comes with a fix
16
+ ludra play [folder] [options] Simulate the game headlessly
17
+ --inputs "right:1s, jump:0.4s" Scripted input. Actions: left right up down jump action wait
18
+ --duration 3s Run length (default: inputs + 1s)
19
+ --watch player,enemy Entities in the timeline (default: platformer entities)
20
+ --every 0.25s Timeline sampling interval
21
+ --ascii Print the final frame as a text map
22
+ --columns 120 Width of the text map (default: 80)
23
+ --json Machine-readable output
24
+ ludra test [folder] [--json] Run tests/*.test.json
25
+ ludra components [name] [--json] Reference for every component and field
26
+ ludra dev [folder] [--port 5173] Play the game in the browser with live reload
27
+
28
+ Folder defaults to the current directory.`;
29
+ function main(argv) {
30
+ const { values, positionals } = parseArgs({
31
+ args: argv,
32
+ allowPositionals: true,
33
+ options: {
34
+ inputs: { type: "string" },
35
+ duration: { type: "string" },
36
+ watch: { type: "string" },
37
+ every: { type: "string" },
38
+ ascii: { type: "boolean" },
39
+ columns: { type: "string" },
40
+ json: { type: "boolean" },
41
+ template: { type: "string" },
42
+ port: { type: "string" },
43
+ help: { type: "boolean", short: "h" },
44
+ },
45
+ });
46
+ const [command, target] = positionals;
47
+ if (values.help || !command || command === "help") {
48
+ console.log(HELP);
49
+ return 0;
50
+ }
51
+ switch (command) {
52
+ case "new":
53
+ return create(target, values);
54
+ case "check":
55
+ return check(target ?? ".", values);
56
+ case "play":
57
+ return play(target ?? ".", values);
58
+ case "test":
59
+ return test(target ?? ".", values);
60
+ case "components":
61
+ return components(target, values);
62
+ case "dev":
63
+ return dev(target ?? ".", values);
64
+ default:
65
+ throw new CliError(`Unknown command "${command}".\n\n${HELP}`);
66
+ }
67
+ }
68
+ try {
69
+ process.exitCode = main(process.argv.slice(2));
70
+ }
71
+ catch (err) {
72
+ // parseArgs reports unknown flags with a code; show those like our own errors.
73
+ const known = err instanceof CliError || err.code?.startsWith("ERR_PARSE_ARGS");
74
+ console.error(known ? paint("red", err.message) : err);
75
+ process.exitCode = 1;
76
+ }
@@ -0,0 +1,29 @@
1
+ import { type Diagnostic, type SceneDef } from "@ludra/core";
2
+ /** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
3
+ export declare class CliError extends Error {
4
+ }
5
+ export interface ProjectConfig {
6
+ name: string;
7
+ entry: string;
8
+ window: {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ }
13
+ export interface Project {
14
+ dir: string;
15
+ config: ProjectConfig;
16
+ entryPath: string;
17
+ /** Entry path relative to the project, for messages. */
18
+ entryLabel: string;
19
+ }
20
+ export declare function loadProject(dir: string): Project;
21
+ export interface SceneLoad {
22
+ scene?: SceneDef;
23
+ diagnostics: Diagnostic[];
24
+ }
25
+ /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
26
+ export declare function readScene(project: Project): SceneLoad;
27
+ export declare function isInside(dir: string, path: string): boolean;
28
+ /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
29
+ export declare function requireScene(project: Project): SceneDef;
@@ -0,0 +1,94 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { collectAssetPaths, validateScene } from "@ludra/core";
4
+ import { formatDiagnostics } from "./format.js";
5
+ /** An error whose message is meant for the user (or agent) as-is, without a stack trace. */
6
+ export class CliError extends Error {
7
+ }
8
+ export function loadProject(dir) {
9
+ const root = resolve(dir);
10
+ const configPath = join(root, "ludra.json");
11
+ if (!existsSync(configPath)) {
12
+ throw new CliError(`No ludra.json found in ${root}.\nRun \`ludra new <folder>\` to create a game, or pass the project folder as an argument.`);
13
+ }
14
+ let raw;
15
+ try {
16
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
17
+ }
18
+ catch (err) {
19
+ throw new CliError(`ludra.json is not valid JSON: ${err.message}`);
20
+ }
21
+ const config = {
22
+ name: raw.name ?? "untitled",
23
+ entry: raw.entry ?? "scenes/main.scene.json",
24
+ window: { width: raw.window?.width ?? 960, height: raw.window?.height ?? 540 },
25
+ };
26
+ const entryPath = join(root, config.entry);
27
+ return { dir: root, config, entryPath, entryLabel: relative(root, entryPath).replaceAll("\\", "/") };
28
+ }
29
+ /** Reads and validates the entry scene. JSON syntax errors become diagnostics too. */
30
+ export function readScene(project) {
31
+ if (!existsSync(project.entryPath)) {
32
+ return {
33
+ diagnostics: [
34
+ {
35
+ severity: "error",
36
+ code: "E_SCENE_MISSING",
37
+ path: "$",
38
+ message: `Entry scene ${project.entryLabel} does not exist.`,
39
+ fix: `Create it, or point "entry" in ludra.json at an existing scene.`,
40
+ },
41
+ ],
42
+ };
43
+ }
44
+ let raw;
45
+ try {
46
+ raw = JSON.parse(readFileSync(project.entryPath, "utf8"));
47
+ }
48
+ catch (err) {
49
+ return {
50
+ diagnostics: [{ severity: "error", code: "E_JSON_SYNTAX", path: "$", message: `Invalid JSON: ${err.message}` }],
51
+ };
52
+ }
53
+ const { scene, diagnostics } = validateScene(raw);
54
+ if (!scene)
55
+ return { diagnostics };
56
+ const missing = missingAssets(project, scene);
57
+ return { scene: missing.length === 0 ? scene : undefined, diagnostics: [...diagnostics, ...missing] };
58
+ }
59
+ /** Image and audio files the scene references that are not in the project. */
60
+ function missingAssets(project, scene) {
61
+ return collectAssetPaths(scene)
62
+ .filter((ref) => !isInside(project.dir, resolve(project.dir, ref.path)) || !existsSync(resolve(project.dir, ref.path)))
63
+ .map((ref) => {
64
+ const available = listAssets(project.dir);
65
+ return {
66
+ severity: "error",
67
+ code: "E_ASSET_MISSING",
68
+ path: ref.at,
69
+ 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").`,
71
+ };
72
+ });
73
+ }
74
+ function listAssets(dir) {
75
+ const assetsDir = join(dir, "assets");
76
+ if (!existsSync(assetsDir))
77
+ return [];
78
+ return readdirSync(assetsDir, { recursive: true, withFileTypes: true })
79
+ .filter((entry) => entry.isFile())
80
+ .map((entry) => relative(dir, join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
81
+ .sort();
82
+ }
83
+ export function isInside(dir, path) {
84
+ const rel = relative(dir, path);
85
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
86
+ }
87
+ /** Like `readScene`, but stops with the diagnostics when the scene has errors. */
88
+ export function requireScene(project) {
89
+ const { scene, diagnostics } = readScene(project);
90
+ if (!scene) {
91
+ throw new CliError(`${formatDiagnostics(diagnostics, project.entryLabel)}\n\nFix the errors above (see \`ludra check\`) and try again.`);
92
+ }
93
+ return scene;
94
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@ludra/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "The game engine built for AI agents. The ludra command: create, check, play, test and run Ludra games.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ersingvn/ludra.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/ersingvn/ludra#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ersingvn/ludra/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.18"
17
+ },
18
+ "scripts": {
19
+ "prepack": "tsc -b --clean && tsc -b"
20
+ },
21
+ "type": "module",
22
+ "bin": {
23
+ "ludra": "dist/index.js"
24
+ },
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "ludra-dev": "./src/index.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "templates"
35
+ ],
36
+ "dependencies": {
37
+ "@ludra/core": "0.3.1"
38
+ },
39
+ "keywords": [
40
+ "game-engine",
41
+ "ai",
42
+ "agents",
43
+ "claude",
44
+ "llm",
45
+ "gamedev",
46
+ "headless-testing"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,30 @@
1
+ # {{name}}
2
+
3
+ A game built with Ludra, a game engine designed for AI agents.
4
+
5
+ ## Layout
6
+
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.
9
+ - `assets/`: SVG art. Spritesheets are frames side by side; tiles repeat.
10
+ - `tests/*.test.json`: gameplay tests, run headlessly
11
+
12
+ ## Loop
13
+
14
+ 1. Edit `scenes/main.scene.json`. Run `npx @ludra/cli components` for every component and field.
15
+ 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
+ 4. Write the behavior you want into `tests/*.test.json` and run `npx @ludra/cli test`.
18
+ 5. Run `npx @ludra/cli dev` so a human can play it in the browser.
19
+
20
+ Never call a change done until `ludra check` and `ludra test` both pass.
21
+
22
+ ## Rules of thumb
23
+
24
+ - Coordinates are entity centers in pixels; +y is down.
25
+ - Jump height = jumpSpeed² / (2 · gravity). The defaults (680, 1800) give about 128px.
26
+ - Collectibles, hazards and goals need `"sensor": true` on their body.
27
+ - Moving platforms and enemies are kinematic bodies with a `patrol`. Players standing on a moving platform are carried.
28
+ - A jump only triggers on a new press, so put a segment without jump between two jump segments.
29
+ - The level is done when a test proves `won == true` with `deaths == 0`.
30
+ - Art and effects never change gameplay. Make it playable first, then dress it. Write new art as SVG in `assets/`.
@@ -0,0 +1,22 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="80" height="20" viewBox="0 0 80 20">
2
+ <!-- Spritesheet: 4 frames of 20x20, a spinning coin. -->
3
+ <g>
4
+ <circle cx="10" cy="10" r="9" fill="#e0a526"/>
5
+ <circle cx="10" cy="10" r="7" fill="#ffd166"/>
6
+ <rect x="8.5" y="5" width="3" height="10" rx="1.5" fill="#e0a526"/>
7
+ <circle cx="7" cy="6.5" r="1.6" fill="#fff3b0"/>
8
+ </g>
9
+ <g transform="translate(20 0)">
10
+ <ellipse cx="10" cy="10" rx="6.5" ry="9" fill="#e0a526"/>
11
+ <ellipse cx="10" cy="10" rx="4.8" ry="7" fill="#ffd166"/>
12
+ <rect x="9" y="5" width="2" height="10" rx="1" fill="#e0a526"/>
13
+ </g>
14
+ <g transform="translate(40 0)">
15
+ <ellipse cx="10" cy="10" rx="2.2" ry="9" fill="#e0a526"/>
16
+ <rect x="9.3" y="2" width="1.4" height="16" fill="#fff3b0"/>
17
+ </g>
18
+ <g transform="translate(60 0)">
19
+ <ellipse cx="10" cy="10" rx="6.5" ry="9" fill="#e0a526"/>
20
+ <ellipse cx="10" cy="10" rx="4.8" ry="7" fill="#f2bb3d"/>
21
+ </g>
22
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="80" viewBox="0 0 48 80">
2
+ <!-- Spritesheet: 2 frames of 24x80, a waving goal flag. -->
3
+ <g>
4
+ <rect x="3" y="4" width="3" height="76" rx="1.5" fill="#cfd8ff"/>
5
+ <circle cx="4.5" cy="4" r="3" fill="#ffd166"/>
6
+ <path d="M6 8 C 12 5, 16 11, 24 8 L 24 26 C 16 29, 12 23, 6 26 Z" fill="#4cc9f0"/>
7
+ <path d="M6 18 C 12 15, 16 21, 24 18 L 24 26 C 16 29, 12 23, 6 26 Z" fill="#3aa6c8"/>
8
+ </g>
9
+ <g transform="translate(24 0)">
10
+ <rect x="3" y="4" width="3" height="76" rx="1.5" fill="#cfd8ff"/>
11
+ <circle cx="4.5" cy="4" r="3" fill="#ffd166"/>
12
+ <path d="M6 8 C 12 11, 16 5, 24 9 L 24 27 C 16 23, 12 29, 6 26 Z" fill="#4cc9f0"/>
13
+ <path d="M6 18 C 12 21, 16 15, 24 19 L 24 27 C 16 23, 12 29, 6 26 Z" fill="#3aa6c8"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
2
+ <!-- Tile: grass-topped dirt, repeats across the ground. -->
3
+ <rect width="40" height="40" fill="#6b4f3a"/>
4
+ <rect y="22" width="40" height="18" fill="#5c4332"/>
5
+ <circle cx="8" cy="20" r="2" fill="#80624a"/>
6
+ <circle cx="28" cy="30" r="2.5" fill="#80624a"/>
7
+ <circle cx="34" cy="16" r="1.5" fill="#4d3829"/>
8
+ <circle cx="16" cy="34" r="1.5" fill="#4d3829"/>
9
+ <rect width="40" height="9" fill="#5fbf6a"/>
10
+ <rect y="7" width="40" height="3" fill="#43944d"/>
11
+ <path d="M3 9 l 2 -6 l 2 6 M14 9 l 1.5 -5 l 1.5 5 M24 9 l 2 -7 l 2 7 M33 9 l 1.5 -4 l 1.5 4" fill="#7ed98a"/>
12
+ </svg>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
2
+ <!-- Tile: riveted metal with hazard stripes for moving platforms. -->
3
+ <rect width="20" height="20" fill="#8fa3d9"/>
4
+ <rect width="20" height="3" fill="#c3d0f2"/>
5
+ <rect y="14" width="20" height="6" fill="#f2c14e"/>
6
+ <path d="M0 20 L 6 14 L 10 14 L 4 20 Z M10 20 L 16 14 L 20 14 L 14 20 Z" fill="#2b3150"/>
7
+ <circle cx="5" cy="8" r="1.4" fill="#5d6f9e"/>
8
+ <circle cx="15" cy="8" r="1.4" fill="#5d6f9e"/>
9
+ </svg>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
2
+ <!-- Tile: stone brick for ledges and steps. -->
3
+ <rect width="20" height="20" fill="#56658f"/>
4
+ <rect width="20" height="3" fill="#7a8ab8"/>
5
+ <rect y="17" width="20" height="3" fill="#434f72"/>
6
+ <rect x="9" y="3" width="2" height="14" fill="#434f72"/>
7
+ <rect x="1" y="5" width="3" height="2" fill="#6b7aa6"/>
8
+ <rect x="13" y="9" width="4" height="2" fill="#6b7aa6"/>
9
+ </svg>
@@ -0,0 +1,65 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="140" height="36" viewBox="0 0 140 36">
2
+ <!-- Spritesheet: 5 frames of 28x36, facing right. 0 idle, 1-2 run, 3 jump, 4 fall. -->
3
+ <defs>
4
+ <g id="eyes">
5
+ <ellipse cx="15" cy="15" rx="3.2" ry="3.8" fill="#ffffff"/>
6
+ <ellipse cx="21.5" cy="15" rx="3.2" ry="3.8" fill="#ffffff"/>
7
+ <circle cx="16.3" cy="15.6" r="1.7" fill="#1d2333"/>
8
+ <circle cx="22.8" cy="15.6" r="1.7" fill="#1d2333"/>
9
+ </g>
10
+ <path id="leaf" d="M14 7 q 2 -6 8 -5 q -2 5 -8 5 z" fill="#4cc9a0"/>
11
+ </defs>
12
+
13
+ <!-- 0: idle -->
14
+ <g>
15
+ <rect x="7" y="29" width="6" height="6" rx="2" fill="#3f9e5c"/>
16
+ <rect x="16" y="29" width="6" height="6" rx="2" fill="#3f9e5c"/>
17
+ <rect x="3" y="7" width="22" height="24" rx="8" fill="#7cf29c"/>
18
+ <rect x="3" y="21" width="22" height="10" rx="5" fill="#62d886"/>
19
+ <use href="#leaf"/>
20
+ <use href="#eyes"/>
21
+ </g>
22
+
23
+ <!-- 1: run, legs apart -->
24
+ <g transform="translate(28 0)">
25
+ <rect x="3" y="28" width="6" height="6" rx="2" fill="#3f9e5c"/>
26
+ <rect x="19" y="29" width="6" height="6" rx="2" fill="#3f9e5c"/>
27
+ <rect x="3" y="6" width="22" height="24" rx="8" fill="#7cf29c"/>
28
+ <rect x="3" y="20" width="22" height="10" rx="5" fill="#62d886"/>
29
+ <use href="#leaf" transform="translate(0 -1)"/>
30
+ <use href="#eyes" transform="translate(0 -1)"/>
31
+ </g>
32
+
33
+ <!-- 2: run, legs together -->
34
+ <g transform="translate(56 0)">
35
+ <rect x="10" y="29" width="6" height="6" rx="2" fill="#3f9e5c"/>
36
+ <rect x="14" y="28" width="6" height="6" rx="2" fill="#3f9e5c"/>
37
+ <rect x="3" y="7" width="22" height="24" rx="8" fill="#7cf29c"/>
38
+ <rect x="3" y="21" width="22" height="10" rx="5" fill="#62d886"/>
39
+ <use href="#leaf"/>
40
+ <use href="#eyes"/>
41
+ </g>
42
+
43
+ <!-- 3: jump, stretched -->
44
+ <g transform="translate(84 0)">
45
+ <rect x="8" y="28" width="5" height="7" rx="2" fill="#3f9e5c"/>
46
+ <rect x="15" y="28" width="5" height="7" rx="2" fill="#3f9e5c"/>
47
+ <rect x="4" y="3" width="20" height="27" rx="8" fill="#7cf29c"/>
48
+ <rect x="4" y="19" width="20" height="11" rx="5" fill="#62d886"/>
49
+ <use href="#leaf" transform="translate(0 -4)"/>
50
+ <use href="#eyes" transform="translate(-0.5 -3)"/>
51
+ </g>
52
+
53
+ <!-- 4: fall, squashed, looking down -->
54
+ <g transform="translate(112 0)">
55
+ <rect x="4" y="29" width="7" height="6" rx="2" fill="#3f9e5c"/>
56
+ <rect x="17" y="29" width="7" height="6" rx="2" fill="#3f9e5c"/>
57
+ <rect x="1" y="10" width="26" height="21" rx="8" fill="#7cf29c"/>
58
+ <rect x="1" y="22" width="26" height="9" rx="4.5" fill="#62d886"/>
59
+ <use href="#leaf" transform="translate(0 3)"/>
60
+ <ellipse cx="15" cy="18" rx="3.2" ry="3.8" fill="#ffffff"/>
61
+ <ellipse cx="21.5" cy="18" rx="3.2" ry="3.8" fill="#ffffff"/>
62
+ <circle cx="16" cy="20" r="1.7" fill="#1d2333"/>
63
+ <circle cx="22.5" cy="20" r="1.7" fill="#1d2333"/>
64
+ </g>
65
+ </svg>
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="26" viewBox="0 0 64 26">
2
+ <!-- Spritesheet: 2 frames of 32x26, a bouncing slime facing right. -->
3
+ <g>
4
+ <path d="M2 25 C 2 10, 8 3, 16 3 C 24 3, 30 10, 30 25 Z" fill="#c77dff"/>
5
+ <path d="M5 25 C 5 18, 9 14, 13 14 C 9 17, 8 21, 8 25 Z" fill="#e0b0ff" opacity="0.7"/>
6
+ <path d="M14 11 l 6 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
7
+ <path d="M27 11 l -4 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
8
+ <circle cx="18" cy="15" r="2" fill="#2a1440"/>
9
+ <circle cx="24" cy="15" r="2" fill="#2a1440"/>
10
+ <path d="M17 20 q 4 2 8 0" stroke="#2a1440" stroke-width="1.5" fill="none" stroke-linecap="round"/>
11
+ </g>
12
+ <g transform="translate(32 0)">
13
+ <path d="M0 25 C 0 14, 7 8, 16 8 C 25 8, 32 14, 32 25 Z" fill="#c77dff"/>
14
+ <path d="M3 25 C 3 20, 7 17, 11 17 C 7 19, 6 22, 6 25 Z" fill="#e0b0ff" opacity="0.7"/>
15
+ <path d="M14 15 l 6 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
16
+ <path d="M28 15 l -4 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
17
+ <circle cx="18" cy="18.5" r="2" fill="#2a1440"/>
18
+ <circle cx="24.5" cy="18.5" r="2" fill="#2a1440"/>
19
+ <path d="M17 22.5 q 4 1.5 8 0" stroke="#2a1440" stroke-width="1.5" fill="none" stroke-linecap="round"/>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="16" viewBox="0 0 20 16">
2
+ <!-- Tile: repeats horizontally across the hazard. -->
3
+ <path d="M0 16 L 5 1 L 10 16 Z" fill="#e4e8f2"/>
4
+ <path d="M5 1 L 10 16 L 6.5 16 Z" fill="#a9b1c7"/>
5
+ <path d="M10 16 L 15 1 L 20 16 Z" fill="#e4e8f2"/>
6
+ <path d="M15 1 L 20 16 L 16.5 16 Z" fill="#a9b1c7"/>
7
+ <rect x="0" y="13" width="20" height="3" fill="#ff5d73"/>
8
+ </svg>
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "entry": "scenes/main.scene.json",
4
+ "window": { "width": 960, "height": 540 }
5
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "ludra dev",
7
+ "check": "ludra check",
8
+ "test": "ludra test"
9
+ },
10
+ "devDependencies": {
11
+ "@ludra/cli": "^0.3.1"
12
+ }
13
+ }
@@ -0,0 +1,154 @@
1
+ {
2
+ "name": "Level 1",
3
+ "world": { "width": 1920, "height": 540, "gravity": 1800, "background": "#1b2140" },
4
+ "effects": {
5
+ "jump": { "sound": "jump", "particles": { "count": 6, "color": "#cfd8ff", "speed": 60, "direction": 90, "spread": 150, "life": 0.25, "size": 3, "gravity": 0 } },
6
+ "land": { "sound": "thud", "particles": { "count": 5, "color": "#cfd8ff", "speed": 50, "direction": -90, "spread": 160, "life": 0.2, "size": 2, "gravity": 300 } },
7
+ "collect": { "sound": "coin", "particles": { "count": 14, "color": "#ffd166", "speed": 140, "life": 0.45, "size": 3, "gravity": 200 } },
8
+ "stomp": { "sound": "hit", "shake": 5, "particles": { "count": 18, "color": "#c77dff", "speed": 180, "life": 0.5, "size": 4 } },
9
+ "die": { "sound": "explosion", "shake": 10, "particles": { "count": 30, "color": "#ff5d73", "speed": 220, "life": 0.7, "size": 4 } },
10
+ "win": { "sound": "powerup", "shake": 3, "particles": { "count": 60, "color": "#4cc9f0", "speed": 260, "life": 1.2, "size": 4, "gravity": 150 } }
11
+ },
12
+ "entities": [
13
+ {
14
+ "id": "player",
15
+ "components": {
16
+ "transform": { "x": 80, "y": 482 },
17
+ "body": { "type": "dynamic", "width": 28, "height": 36 },
18
+ "sprite": {
19
+ "color": "#7cf29c",
20
+ "image": "assets/player.svg",
21
+ "frameWidth": 28,
22
+ "frameHeight": 36,
23
+ "idle": "0",
24
+ "run": "1-2",
25
+ "jump": "3",
26
+ "fall": "4",
27
+ "fps": 10,
28
+ "layer": 3
29
+ },
30
+ "platformer": { "speed": 240, "jumpSpeed": 680 },
31
+ "camera": {}
32
+ }
33
+ },
34
+ {
35
+ "id": "ground-start",
36
+ "components": {
37
+ "transform": { "x": 380, "y": 520 },
38
+ "body": { "type": "static", "width": 760, "height": 40 },
39
+ "sprite": { "color": "#3b4766", "image": "assets/ground.svg", "tile": true }
40
+ }
41
+ },
42
+ {
43
+ "id": "ground-end",
44
+ "components": {
45
+ "transform": { "x": 1450, "y": 520 },
46
+ "body": { "type": "static", "width": 940, "height": 40 },
47
+ "sprite": { "color": "#3b4766", "image": "assets/ground.svg", "tile": true }
48
+ }
49
+ },
50
+ {
51
+ "id": "wall-left",
52
+ "components": {
53
+ "transform": { "x": -10, "y": 270 },
54
+ "body": { "type": "static", "width": 20, "height": 540 }
55
+ }
56
+ },
57
+ {
58
+ "id": "wall-right",
59
+ "components": {
60
+ "transform": { "x": 1930, "y": 270 },
61
+ "body": { "type": "static", "width": 20, "height": 540 }
62
+ }
63
+ },
64
+ {
65
+ "id": "ledge",
66
+ "components": {
67
+ "transform": { "x": 300, "y": 410 },
68
+ "body": { "type": "static", "width": 160, "height": 20 },
69
+ "sprite": { "color": "#56658f", "image": "assets/platform.svg", "tile": true }
70
+ }
71
+ },
72
+ {
73
+ "id": "coin-1",
74
+ "components": {
75
+ "transform": { "x": 300, "y": 370 },
76
+ "body": { "type": "static", "width": 20, "height": 20, "sensor": true },
77
+ "sprite": { "shape": "circle", "color": "#ffd166", "image": "assets/coin.svg", "frameWidth": 20, "idle": "0-3", "layer": 1 },
78
+ "collectible": { "score": 1 }
79
+ }
80
+ },
81
+ {
82
+ "id": "spikes",
83
+ "components": {
84
+ "transform": { "x": 560, "y": 492 },
85
+ "body": { "type": "static", "width": 80, "height": 16, "sensor": true },
86
+ "sprite": { "color": "#ff5d73", "image": "assets/spikes.svg", "tile": true, "layer": 1 },
87
+ "hazard": {}
88
+ }
89
+ },
90
+ {
91
+ "id": "lift",
92
+ "components": {
93
+ "transform": { "x": 800, "y": 440 },
94
+ "body": { "type": "kinematic", "width": 120, "height": 20 },
95
+ "sprite": { "color": "#8fa3d9", "image": "assets/lift.svg", "tile": true },
96
+ "patrol": { "axis": "x", "distance": 140, "speed": 90 }
97
+ }
98
+ },
99
+ {
100
+ "id": "coin-2",
101
+ "components": {
102
+ "transform": { "x": 870, "y": 390 },
103
+ "body": { "type": "static", "width": 20, "height": 20, "sensor": true },
104
+ "sprite": { "shape": "circle", "color": "#ffd166", "image": "assets/coin.svg", "frameWidth": 20, "idle": "0-3", "layer": 1 },
105
+ "collectible": { "score": 1 }
106
+ }
107
+ },
108
+ {
109
+ "id": "slime",
110
+ "components": {
111
+ "transform": { "x": 1150, "y": 487 },
112
+ "body": { "type": "kinematic", "width": 32, "height": 26, "sensor": true },
113
+ "sprite": { "color": "#c77dff", "image": "assets/slime.svg", "frameWidth": 32, "idle": "0", "run": "0-1", "fps": 4, "layer": 2 },
114
+ "patrol": { "axis": "x", "distance": 200, "speed": 70 },
115
+ "hazard": { "stompable": true, "score": 1 }
116
+ }
117
+ },
118
+ {
119
+ "id": "step-1",
120
+ "components": {
121
+ "transform": { "x": 1500, "y": 410 },
122
+ "body": { "type": "static", "width": 140, "height": 20 },
123
+ "sprite": { "color": "#56658f", "image": "assets/platform.svg", "tile": true }
124
+ }
125
+ },
126
+ {
127
+ "id": "step-2",
128
+ "components": {
129
+ "transform": { "x": 1680, "y": 330 },
130
+ "body": { "type": "static", "width": 140, "height": 20 },
131
+ "sprite": { "color": "#56658f", "image": "assets/platform.svg", "tile": true }
132
+ }
133
+ },
134
+ {
135
+ "id": "coin-3",
136
+ "components": {
137
+ "transform": { "x": 1680, "y": 290 },
138
+ "body": { "type": "static", "width": 20, "height": 20, "sensor": true },
139
+ "sprite": { "shape": "circle", "color": "#ffd166", "image": "assets/coin.svg", "frameWidth": 20, "idle": "0-3", "layer": 1 },
140
+ "collectible": { "score": 1 }
141
+ }
142
+ },
143
+ {
144
+ "id": "flag",
145
+ "components": {
146
+ "transform": { "x": 1860, "y": 460 },
147
+ "body": { "type": "static", "width": 24, "height": 80, "sensor": true },
148
+ "sprite": { "color": "#4cc9f0", "image": "assets/flag.svg", "frameWidth": 24, "idle": "0-1", "fps": 3, "flip": false, "layer": 1 },
149
+ "emitter": { "rate": 6, "color": "#fff3b0", "speed": 30, "spread": 70, "life": 1.2, "size": 3, "gravity": -20 },
150
+ "goal": { "requireScore": 4 }
151
+ }
152
+ }
153
+ ]
154
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "tests": [
3
+ {
4
+ "name": "player rests on the ground",
5
+ "duration": "1s",
6
+ "assert": ["player.grounded == true", "player.y == 482", "deaths == 0"]
7
+ },
8
+ {
9
+ "name": "player runs right",
10
+ "inputs": "right:1s",
11
+ "assert": ["player.x > 300 at 1s", "player.grounded == true at 1s"]
12
+ },
13
+ {
14
+ "name": "jumping leaves the ground and lands again",
15
+ "inputs": "wait:0.2s, jump:0.4s",
16
+ "assert": ["events.jump == 1", "player.y < 400 at 0.5s", "player.grounded == true at end"]
17
+ },
18
+ {
19
+ "name": "spikes kill the player, who respawns at the start",
20
+ "inputs": "right:1.85s",
21
+ "duration": "2s",
22
+ "assert": ["deaths == 1", "events.die == 1", "player.x < 200"]
23
+ },
24
+ {
25
+ "name": "the lift carries the player over the pit",
26
+ "inputs": "right:0.3s, right+jump:0.45s, right:0.85s, right+jump:0.45s, right:0.35s, right+jump:0.45s, right:0.18s",
27
+ "duration": "4s",
28
+ "assert": ["coin-2.exists == false", "player.grounded == true", "player.x > 860", "deaths == 0"]
29
+ },
30
+ {
31
+ "name": "the level can be completed without dying",
32
+ "inputs": "right:0.3s, right+jump:0.45s, right:0.85s, right+jump:0.45s, right:0.35s, right+jump:0.45s, right:0.18s, wait:1.0s, right+jump:0.45s, right:0.3s, wait:0.6s, right+jump:0.45s, right:0.1s, wait:0.6s, right:0.7s, right+jump:0.45s, right:0.2s, right+jump:0.45s, right:1.5s",
33
+ "assert": ["won == true", "deaths == 0", "score == 4", "events.stomp == 1", "slime.exists == false"]
34
+ }
35
+ ]
36
+ }