@netforge/kit-play 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,48 +1,86 @@
1
1
  # @netforge/kit-play
2
2
 
3
- `kit play` runs a game you downloaded from Net Forge on your own computer,
4
- with play money.
3
+ Part of Net Forge, the open-source multiplayer browser game engine built for AI coding agents (`@netforge` on npm, https://github.com/netnetdev/net-forge and https://forge.netnet.capital/docs). Not affiliated with other projects named NetForge.
4
+
5
+ `kit play` runs a Net Forge game on your own computer with play money: it builds the template's browser client with your `src/game.ts` and starts a local room host for the same game module, with no wallet, no chain and no Net Forge servers.
6
+ It also starts new games from a template and tests them with playtest bots.
7
+
8
+ | Command | What it does |
9
+ |---|---|
10
+ | `kit-play play [dir]` | Build the game in `dir` (default: here) and play it. `kit-play [dir]` does the same. |
11
+ | `kit-play test [dir]` | Compile `src/game.ts` as the play build does and run the playtest bots. No browser. Exits 1 on a failure; `--json` prints the report. |
12
+ | `kit-play check [dir]` | `kit-play test` with the report as JSON on stdout. |
13
+ | `kit-play new [folder] --template <id>` | Start a new game. The same as `npm create netforge@latest`; see [create-netforge](../create-netforge). |
14
+ | `kit-play templates [--json]` | List the ten templates. |
15
+
16
+ `kit-play test` takes `--minutes <n>` (minutes of game time at the
17
+ template's own tick rate, default 1), `--bots <n>` and `--seed <n>`.
18
+
19
+ With `--host 0.0.0.0`, `kit-play play` also prints a link for each network
20
+ address of this computer. Open that link on a phone or another computer:
21
+ it carries `?server=`, which tells the page which room to join.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install @netforge/kit-play
27
+ ```
28
+
29
+ Node 20 or later. The package brings the engine, the asset library files and every template with it. The bin is `kit-play`; `kit play` through `@netforge/kit` runs the same command.
30
+
31
+ ## Example
32
+
33
+ A game folder needs `netnet.json` (which template) and `src/game.ts` (the game). `src/names.json` (the title and labels) is optional. To start from a template:
5
34
 
6
35
  ```sh
7
- cd my-game # the folder from the Source download (netnet.json, src/game.ts)
8
- npm install # its package.json lists the engine and kit-play
9
- npx kit play # or: npm run play
36
+ mkdir -p my-game/src && cd my-game
37
+ npm init -y && npm install @netforge/kit-play @netforge/engine
38
+ cp node_modules/@netforge/template-survivor/src/game.ts src/
39
+ echo '{"template":"survivor"}' > netnet.json
40
+ npx kit-play # opens http://127.0.0.1:4663
41
+ npx kit-play --players 2 # two tabs, two local players
10
42
  ```
11
43
 
12
- It builds the template's browser client with your `src/game.ts` and
13
- `src/names.json` in place, starts a local room host for the same game
14
- module, and opens `http://127.0.0.1:4663`. Open the link in more tabs or
15
- windows for more players.
44
+ Edit `src/game.ts` and run it again to try a change. Open the link in more tabs or windows for more players. The build lands in `.netnet/play/` inside the game folder.
45
+
46
+ Template ids for `netnet.json`: `survivor`, `arena`, `tower-defense`, `dungeon`, `idle`, `platformer`, `cards`, `collector`, `racer`, `party`.
47
+
48
+ ## Flags
16
49
 
17
50
  | Flag | Default | |
18
51
  |---|---|---|
19
52
  | `--port <n>` | 4663 | the page and the room, one port |
20
53
  | `--host <addr>` | 127.0.0.1 | `0.0.0.0` lets devices on your network join |
21
- | `--players <n>` | 1 | browser tabs to open |
54
+ | `--players <n>` | 1 | browser tabs to open (1 to 16) |
22
55
  | `--no-open` | | print the link only |
23
56
  | `--library-url <url>` | the installed library | load models and sounds from a mirror instead |
24
57
 
58
+ From code: `import { play } from "@netforge/kit-play"` takes `{ dir, port, host, open, players, libraryUrl, log }` and resolves to `{ url, close() }`. `readGameFolder(dir)` checks a folder without running it, and `TEMPLATES` lists the templates it can run.
59
+
25
60
  ## What it does not do
26
61
 
27
62
  - No wallet, no chain, no real tokens. Coins are play money in the page.
28
- - No calls to Net Forge servers. The page is built with no `VITE_*`
29
- environment and talks only to the local host; sign-in, the referee and
30
- every money route answer 403.
31
- - No stakes, pots or claims. Earn rules still fire, so you can see them,
32
- and pay nothing.
33
-
34
- The local host is built from the engine's open simulation
35
- (`@netforge/engine`, `@netforge/engine-sim`). The hosted room
36
- server and the referee that decides real payouts are not part of it.
37
- Progress (achievements, looks bought with play money) lives in memory and
38
- is gone when `kit play` stops.
39
-
40
- kit play is not a sandbox: it runs the game's code (the room host in Node,
41
- the client in your browser) with your permissions. Play only games whose
42
- code you trust, as with any program you run.
43
-
44
- `src/game.ts` may import only `@netforge/engine`, as in the studio, so
45
- a game that plays here plays there. Send changes back with `/agent` in the
46
- studio (see your game's README).
47
-
48
- The build lands in `.netnet/play/` inside the game folder.
63
+ - No calls to Net Forge servers. The page is built with no `VITE_*` environment and talks only to the local host; sign-in, the referee and every money route answer 403.
64
+ - No stakes, pots or claims. Earn rules still fire, so you can see them, and pay nothing.
65
+ - No saved progress. Achievements and looks bought with play money live in memory and are gone when `kit play` stops.
66
+
67
+ The local host is built from the engine's open simulation (`@netforge/engine`, `@netforge/engine-sim`). The hosted room server and the referee that decides real payouts are not part of it.
68
+
69
+ kit play is not a sandbox: it runs the game's code (the room host in Node, the client in your browser) with your permissions. Play only games whose code you trust, as with any program you run.
70
+
71
+ `src/game.ts` may import only `@netforge/engine`, as in the Net Forge studio, so a game that plays here plays there.
72
+
73
+ ## For AI agents
74
+
75
+ - Read `llms.txt` in this package, and the template's own `AGENTS.md` and `llms.txt`.
76
+ - The Net Forge MCP server runs local play for an agent with the `netforge_play_locally` tool, next to `netforge_validate_game` and `netforge_run_playtest`: `npx -y @netforge/mcp` (package `@netforge/mcp`, bin `netforge-mcp`).
77
+ - `npx netforge setup agent` writes AGENTS.md, CLAUDE.md, Cursor rules and `.mcp.json` into a game project. In Claude Code: `claude mcp add netforge -- npx -y @netforge/mcp`.
78
+
79
+ ## Docs
80
+
81
+ - https://forge.netnet.capital/docs/packages/kit-play
82
+ - `llms.txt` in this package
83
+
84
+ ## License
85
+
86
+ MIT. See `LICENSE` and `NOTICE`.
@@ -0,0 +1,43 @@
1
+ export interface CheckOptions {
2
+ readonly dir: string;
3
+ /** Minutes of game time to play, at the template's own tick rate. */
4
+ readonly minutes: number;
5
+ /** Bots playing; null for the template's default. */
6
+ readonly bots: number | null;
7
+ readonly seed: number;
8
+ readonly log: (s: string) => void;
9
+ }
10
+ /** What the engine's playtest reports (the fields this command reads; the rest pass through to --json). */
11
+ export interface CheckReport {
12
+ readonly ticks: number;
13
+ readonly bots: number;
14
+ readonly kills: number;
15
+ readonly deaths: number;
16
+ readonly wave: number;
17
+ readonly maxLevel: number;
18
+ readonly earnEmitted: number;
19
+ readonly earned: Readonly<Record<string, number>>;
20
+ readonly softlock: boolean;
21
+ readonly msPerTick: number;
22
+ readonly rules?: {
23
+ readonly template: string;
24
+ readonly checks: readonly {
25
+ readonly ok: boolean;
26
+ readonly line: string;
27
+ }[];
28
+ readonly progress: number;
29
+ };
30
+ readonly failed: readonly string[];
31
+ }
32
+ export interface CheckResult {
33
+ readonly ok: boolean;
34
+ readonly template: string;
35
+ /** Why it failed, one line each (empty when ok). */
36
+ readonly problems: readonly string[];
37
+ /** Simulation ticks per second of game time (the template's own rate). */
38
+ readonly tickRate?: number;
39
+ readonly report: CheckReport | null;
40
+ }
41
+ export declare function check(o: CheckOptions): Promise<CheckResult>;
42
+ /** The report in a few lines. */
43
+ export declare function summary(r: CheckResult): string[];
package/dist/check.js ADDED
@@ -0,0 +1,106 @@
1
+ // `kit-play test`: compile the game folder's src/game.ts the way the play
2
+ // build does and run the engine's headless playtest (engine.test.run) on
3
+ // it with bots. No browser, no server, no network: a quick check after
4
+ // every change, made for coding agents as much as for people.
5
+ import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { gameSwap, PlayError, readGameFolder, templateDir } from "./play.js";
9
+ /** Bundles the template's manifest with the folder's game module, plus the engine's playtest. Returns the bundle path. */
10
+ async function bundleCheck(gameFile, tplDir, outFile) {
11
+ const esbuild = await import("esbuild");
12
+ const manifest = realpathSync(join(tplDir, "src", "manifest.ts"));
13
+ const contents = [`export * as template from ${JSON.stringify(manifest)};`, `export { engine } from "@netforge/engine";`, `export { initSim } from "@netforge/engine-sim";`].join("\n");
14
+ const out = await esbuild.build({
15
+ stdin: { contents, resolveDir: join(tplDir, "src"), sourcefile: "kit-play-test.js", loader: "js" },
16
+ bundle: true,
17
+ platform: "node",
18
+ format: "esm",
19
+ target: "node20",
20
+ write: false,
21
+ logLevel: "silent",
22
+ plugins: [gameSwap(manifest, realpathSync(gameFile), tplDir)],
23
+ banner: { js: "import { createRequire as __kpRequire } from 'node:module'; const require = __kpRequire(import.meta.url);" },
24
+ });
25
+ const file = out.outputFiles[0];
26
+ if (!file)
27
+ throw new PlayError("the test bundle produced no output");
28
+ mkdirSync(dirname(outFile), { recursive: true });
29
+ writeFileSync(outFile, file.text);
30
+ return outFile;
31
+ }
32
+ /** esbuild's and the engine's errors as the lines a person (or an agent) needs. */
33
+ function problemLines(e) {
34
+ const errors = e.errors;
35
+ if (Array.isArray(errors) && errors.length > 0)
36
+ return errors.map((x) => (x.location ? `${x.location.file}:${x.location.line}:${x.location.column}: ${x.text}` : x.text));
37
+ return [e instanceof Error ? e.message : String(e)];
38
+ }
39
+ export async function check(o) {
40
+ const dir = resolve(o.dir);
41
+ const g = readGameFolder(dir);
42
+ const tplDir = templateDir(g.template, dir);
43
+ const t = g.template;
44
+ o.log(`Compiling src/game.ts (${t.label})...`);
45
+ let mod;
46
+ try {
47
+ // A fresh file name per run: a module imported once stays cached for the process.
48
+ const bundle = await bundleCheck(g.gameFile, tplDir, join(dir, ".netnet", "play", `test-${process.pid}-${Date.now()}.mjs`));
49
+ try {
50
+ mod = (await import(pathToFileURL(bundle).href));
51
+ }
52
+ finally {
53
+ rmSync(bundle, { force: true });
54
+ }
55
+ }
56
+ catch (e) {
57
+ return { ok: false, template: t.id, problems: problemLines(e), report: null };
58
+ }
59
+ let tickRate = 30;
60
+ try {
61
+ await mod.initSim();
62
+ const get = mod.template[t.manifest.fn];
63
+ const make = mod.template[t.adapter];
64
+ if (typeof get !== "function" || typeof make !== "function")
65
+ throw new PlayError(`the template does not export ${t.manifest.fn} and ${t.adapter}`);
66
+ const got = get();
67
+ tickRate = (t.manifest.field ? got.manifest?.game?.tickRate : got.game?.tickRate) ?? 30;
68
+ make();
69
+ }
70
+ catch (e) {
71
+ return { ok: false, template: t.id, problems: problemLines(e), report: null };
72
+ }
73
+ const bots = o.bots ?? Math.min(t.seats, 2);
74
+ // The template's own tick rate (the racer and the arena shooter run at 60 Hz), so a minute is a minute of game time.
75
+ const ticks = Math.round(o.minutes * 60 * tickRate);
76
+ o.log(`Playtest: ${bots} bot${bots === 1 ? "" : "s"} for ${o.minutes} minute${o.minutes === 1 ? "" : "s"} of game time...`);
77
+ let report;
78
+ try {
79
+ report = await mod.engine.test.run({ ticks, bots, seed: o.seed, tickRate });
80
+ }
81
+ catch (e) {
82
+ return { ok: false, template: t.id, problems: problemLines(e), report: null };
83
+ }
84
+ const problems = [...report.failed.map((f) => `check failed: ${f}`), ...(report.rules?.checks ?? []).filter((c) => !c.ok).map((c) => `check failed: ${c.line}`)];
85
+ if (report.softlock)
86
+ problems.push("softlock: nothing changed for 30 seconds of game time (no kills, no damage, no movement)");
87
+ return { ok: problems.length === 0, template: t.id, problems: [...new Set(problems)], tickRate, report };
88
+ }
89
+ /** The report in a few lines. */
90
+ export function summary(r) {
91
+ if (!r.report)
92
+ return [];
93
+ const x = r.report;
94
+ const earned = Object.entries(x.earned)
95
+ .map(([k, v]) => `${k} ${v}`)
96
+ .join(", ");
97
+ return [
98
+ ` kills ${x.kills}, downs ${x.deaths}, wave ${x.wave}, top level ${x.maxLevel}`,
99
+ ` earn rules fired ${x.earnEmitted} time${x.earnEmitted === 1 ? "" : "s"}${earned ? ` (${earned})` : ""}`,
100
+ // A check's line says what went wrong, so only failing ones are shown.
101
+ ...(x.rules ? [` ${x.rules.checks.filter((c) => c.ok).length} of ${x.rules.checks.length} genre checks passed`] : []),
102
+ ...(x.rules ? x.rules.checks.filter((c) => !c.ok).map((c) => ` FAIL ${c.line}`) : []),
103
+ ...(x.rules ? [` progress ${Math.round(x.rules.progress * 100)}%`] : []),
104
+ ` ${x.msPerTick.toFixed(2)} ms per tick on this computer`,
105
+ ];
106
+ }
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  export declare const HELP = "kit play: run a Net Forge game on this computer with play money.\n\nUsage: kit play [dir] [flags] (inside the folder you downloaded from Net Forge)\n\n --port <n> Port for the page and the local room (default 4663).\n --host <addr> Interface to listen on (default 127.0.0.1, this computer only).\n Use 0.0.0.0 to let devices on your network join.\n --players <n> Browser tabs to open, one per local player (default 1).\n --no-open Print the link instead of opening a browser.\n --library-url <u> Load the asset library from this URL instead of the installed copy.\n\nNo wallet, no chain, no Net Forge servers: coins are play money and nothing pays out.\nkit play runs the game's own code on this computer with your permissions (it is not a\nsandbox): play only games whose code you trust.";
3
+ export declare const COMMANDS_HELP = "kit-play: make, test and play Net Forge games on this computer with play money.\n\nUsage:\n kit-play play [dir] [flags] Build the game in dir (default: here) and play it. kit-play play --help for flags.\n kit-play test [dir] [flags] Compile src/game.ts and run the playtest bots, no browser.\n kit-play check [dir] [flags] The same, with the report as JSON on stdout.\n kit-play new [folder] [flags] Start a new game from a template (the same as npm create netforge).\n kit-play templates [--json] List the templates.\n\nkit-play [dir] without a command plays, as before.";
4
+ export declare const TEST_HELP = "kit-play test: compile src/game.ts as the play build does and run the playtest bots.\n\nUsage: kit-play test [dir] [flags]\n\n --minutes <n> Minutes of game time the bots play, at the template's own tick rate (default 1, up to 30).\n --bots <n> Bots playing (default: 2, or fewer when the template seats fewer).\n --seed <n> Random seed (default 1).\n --json Print the result as JSON on stdout.\n\nExits 0 when the game compiles and every playtest check passes, 1 otherwise.";
3
5
  export interface PlayCliIo {
4
6
  out(s: string): void;
5
7
  err(s: string): void;
6
8
  cwd: string;
7
9
  }
8
10
  export declare function runPlayCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
11
+ export declare function runTestCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
12
+ /** The kit-play command: play (the default), test, new and templates. */
13
+ export declare function runKitPlayCli(argv: readonly string[], io: PlayCliIo): Promise<number>;
package/dist/cli.js CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // kit-play [dir] [--port 4663] [--host 127.0.0.1] [--players 1] [--no-open] [--library-url <url>]
3
- // Also reached as `kit play` through @netforge/kit.
2
+ // kit-play [play] [dir] [--port 4663] [--host 127.0.0.1] [--players 1] [--no-open] [--library-url <url>]
3
+ // kit-play test [dir] [--minutes 1] [--bots n] [--seed 1] [--json]
4
+ // kit-play check [dir] ... (test --json)
5
+ // kit-play new [folder] [--template <id>] ... (create-netforge)
6
+ // kit-play templates [--json]
7
+ // `kit play` through @netforge/kit reaches runPlayCli directly.
4
8
  import { realpathSync } from "node:fs";
5
9
  import { fileURLToPath } from "node:url";
10
+ import { listTemplates, nodeIo, runCreate } from "create-netforge";
11
+ import { check, summary } from "./check.js";
6
12
  import { play, PlayError } from "./play.js";
7
13
  export const HELP = `kit play: run a Net Forge game on this computer with play money.
8
14
 
@@ -18,6 +24,26 @@ Usage: kit play [dir] [flags] (inside the folder you downloaded from Net For
18
24
  No wallet, no chain, no Net Forge servers: coins are play money and nothing pays out.
19
25
  kit play runs the game's own code on this computer with your permissions (it is not a
20
26
  sandbox): play only games whose code you trust.`;
27
+ export const COMMANDS_HELP = `kit-play: make, test and play Net Forge games on this computer with play money.
28
+
29
+ Usage:
30
+ kit-play play [dir] [flags] Build the game in dir (default: here) and play it. kit-play play --help for flags.
31
+ kit-play test [dir] [flags] Compile src/game.ts and run the playtest bots, no browser.
32
+ kit-play check [dir] [flags] The same, with the report as JSON on stdout.
33
+ kit-play new [folder] [flags] Start a new game from a template (the same as npm create netforge).
34
+ kit-play templates [--json] List the templates.
35
+
36
+ kit-play [dir] without a command plays, as before.`;
37
+ export const TEST_HELP = `kit-play test: compile src/game.ts as the play build does and run the playtest bots.
38
+
39
+ Usage: kit-play test [dir] [flags]
40
+
41
+ --minutes <n> Minutes of game time the bots play, at the template's own tick rate (default 1, up to 30).
42
+ --bots <n> Bots playing (default: 2, or fewer when the template seats fewer).
43
+ --seed <n> Random seed (default 1).
44
+ --json Print the result as JSON on stdout.
45
+
46
+ Exits 0 when the game compiles and every playtest check passes, 1 otherwise.`;
21
47
  export async function runPlayCli(argv, io) {
22
48
  const flags = new Map();
23
49
  const positional = [];
@@ -79,6 +105,116 @@ export async function runPlayCli(argv, io) {
79
105
  return 1;
80
106
  }
81
107
  }
108
+ /** Splits flags (--k v, --k=v, bare --k when listed in `bare`) from positional arguments. */
109
+ function split(argv, bare) {
110
+ const flags = new Map();
111
+ const positional = [];
112
+ for (let i = 0; i < argv.length; i++) {
113
+ const a = argv[i] ?? "";
114
+ if (a.startsWith("--")) {
115
+ const [k, v] = a.slice(2).split("=", 2);
116
+ const next = argv[i + 1];
117
+ if (v !== undefined)
118
+ flags.set(k, v);
119
+ else if (!bare.has(k) && next !== undefined && !next.startsWith("--"))
120
+ flags.set(k, argv[++i] ?? "");
121
+ else
122
+ flags.set(k, true);
123
+ }
124
+ else if (a === "-h")
125
+ flags.set("help", true);
126
+ else
127
+ positional.push(a);
128
+ }
129
+ return { flags, positional };
130
+ }
131
+ export async function runTestCli(argv, io) {
132
+ const { flags, positional } = split(argv, new Set(["json", "help"]));
133
+ if (flags.has("help")) {
134
+ io.out(TEST_HELP);
135
+ return 0;
136
+ }
137
+ const json = flags.has("json");
138
+ const num = (k, d, min, max, whole) => {
139
+ const v = flags.get(k);
140
+ if (v === undefined)
141
+ return d;
142
+ const n = Number(v);
143
+ if (!Number.isFinite(n) || (whole && !Number.isInteger(n)) || n < min || n > max)
144
+ throw new PlayError(`--${k} must be a ${whole ? "whole " : ""}number from ${min} to ${max}`);
145
+ return n;
146
+ };
147
+ const known = new Set(["json", "help", "minutes", "bots", "seed"]);
148
+ try {
149
+ for (const k of flags.keys())
150
+ if (!known.has(k))
151
+ throw new PlayError(`unknown flag --${k} (kit-play test --help lists the flags)`);
152
+ const bots = flags.has("bots") ? num("bots", 2, 1, 16, true) : null;
153
+ const r = await check({
154
+ dir: positional[0] ?? io.cwd,
155
+ minutes: num("minutes", 1, 0.1, 30, false),
156
+ bots,
157
+ seed: num("seed", 1, 0, 2 ** 31 - 1, true),
158
+ log: json ? io.err : io.out,
159
+ });
160
+ if (json)
161
+ io.out(JSON.stringify(r, null, 2));
162
+ else {
163
+ for (const l of summary(r))
164
+ io.out(l);
165
+ if (r.ok)
166
+ io.out("PASS: the game compiles and the playtest checks pass.");
167
+ else {
168
+ io.err("FAIL:");
169
+ for (const p of r.problems)
170
+ io.err(` ${p}`);
171
+ }
172
+ }
173
+ return r.ok ? 0 : 1;
174
+ }
175
+ catch (e) {
176
+ const msg = e instanceof PlayError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e);
177
+ if (json)
178
+ io.out(JSON.stringify({ ok: false, problems: [msg], report: null }, null, 2));
179
+ else
180
+ io.err(msg);
181
+ return 1;
182
+ }
183
+ }
184
+ /** The kit-play command: play (the default), test, new and templates. */
185
+ export async function runKitPlayCli(argv, io) {
186
+ const [first, ...rest] = argv;
187
+ switch (first) {
188
+ case "play":
189
+ return runPlayCli(rest, io);
190
+ case "test":
191
+ return runTestCli(rest, io);
192
+ case "check":
193
+ // test with the report as JSON on stdout: what scripts and coding agents read.
194
+ return runTestCli(rest.includes("--json") ? rest : [...rest, "--json"], io);
195
+ case "new":
196
+ case "create":
197
+ return runCreate(rest, { ...nodeIo("kit-play new"), out: io.out, err: io.err, cwd: io.cwd });
198
+ case "templates":
199
+ if (rest.includes("--help") || rest.includes("-h")) {
200
+ io.out("kit-play templates [--json]: list the templates a new game can start from.");
201
+ return 0;
202
+ }
203
+ io.out(listTemplates(rest.includes("--json")));
204
+ return 0;
205
+ case "help":
206
+ io.out(COMMANDS_HELP);
207
+ return 0;
208
+ case undefined:
209
+ return runPlayCli([], io);
210
+ default:
211
+ if (first === "--help" || first === "-h") {
212
+ io.out(`${COMMANDS_HELP}\n\n${HELP}`);
213
+ return 0;
214
+ }
215
+ return runPlayCli(argv, io);
216
+ }
217
+ }
82
218
  const isMain = (() => {
83
219
  try {
84
220
  return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
@@ -88,6 +224,6 @@ const isMain = (() => {
88
224
  }
89
225
  })();
90
226
  if (isMain) {
91
- const code = await runPlayCli(process.argv.slice(2), { out: (s) => console.log(s), err: (s) => console.error(s), cwd: process.cwd() });
227
+ const code = await runKitPlayCli(process.argv.slice(2), { out: (s) => console.log(s), err: (s) => console.error(s), cwd: process.cwd() });
92
228
  process.exit(code);
93
229
  }
@@ -19,7 +19,10 @@ export declare function fileUnder(root: string, urlPath: string): string | null;
19
19
  /**
20
20
  * On a loopback bind, only requests addressed to this computer by name are
21
21
  * answered (a page elsewhere that rebinds its own host name to 127.0.0.1
22
- * gets 403), and only same-origin or non-browser requests may POST.
22
+ * gets 403). On any other bind (--host 0.0.0.0 for phones on the network)
23
+ * the Host must still be an address, not a DNS name, so the same rebinding
24
+ * trick fails there too. Either way only same-origin or non-browser
25
+ * requests may POST or open a WebSocket.
23
26
  */
24
27
  export declare function allowedRequest(bindHost: string, port: number, host: string | undefined, origin: string | undefined, method: string | undefined): boolean;
25
28
  export declare function startServer(g: LocalGame, o: HostOptions): Promise<RunningHost>;
@@ -161,22 +161,44 @@ async function route(g, o, req, res) {
161
161
  return true;
162
162
  }
163
163
  const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
164
+ /** The host name of a Host header value ("192.168.1.5:4663", "[fe80::1]:4663"), lower case. */
165
+ function hostName(host) {
166
+ const h = host.trim().toLowerCase();
167
+ if (h.startsWith("[")) {
168
+ const end = h.indexOf("]");
169
+ return end > 0 ? h.slice(0, end + 1) : h;
170
+ }
171
+ return h.replace(/:\d+$/, "");
172
+ }
173
+ /**
174
+ * A name a DNS rebinding page cannot put in the Host header: an IP literal,
175
+ * localhost, or an mDNS .local name (resolved on the network itself, never
176
+ * by a public DNS server an attacker controls).
177
+ */
178
+ function directName(name) {
179
+ return LOOPBACK.has(name) || /^\d{1,3}(\.\d{1,3}){3}$/.test(name) || /^\[[0-9a-f:.]+\]$/.test(name) || /^[a-z0-9-]+(\.[a-z0-9-]+)*\.local$/.test(name);
180
+ }
164
181
  /**
165
182
  * On a loopback bind, only requests addressed to this computer by name are
166
183
  * answered (a page elsewhere that rebinds its own host name to 127.0.0.1
167
- * gets 403), and only same-origin or non-browser requests may POST.
184
+ * gets 403). On any other bind (--host 0.0.0.0 for phones on the network)
185
+ * the Host must still be an address, not a DNS name, so the same rebinding
186
+ * trick fails there too. Either way only same-origin or non-browser
187
+ * requests may POST or open a WebSocket.
168
188
  */
169
189
  export function allowedRequest(bindHost, port, host, origin, method) {
170
- if (!LOOPBACK.has(bindHost))
171
- return true;
172
- const name = (host ?? "").replace(/:\d+$/, "").toLowerCase();
173
- if (!LOOPBACK.has(name))
190
+ const loopback = LOOPBACK.has(bindHost);
191
+ const name = hostName(host ?? "");
192
+ if (loopback ? !LOOPBACK.has(name) : !directName(name))
174
193
  return false;
175
194
  if (method === "GET" || method === "HEAD" || origin === undefined)
176
195
  return true;
177
196
  try {
178
197
  const o = new URL(origin);
179
- return LOOPBACK.has(o.hostname.toLowerCase()) && Number(o.port || 80) === port;
198
+ const oName = o.hostname.toLowerCase();
199
+ if (o.protocol !== "http:" || Number(o.port || 80) !== port)
200
+ return false;
201
+ return loopback ? LOOPBACK.has(oName) : oName === name || (LOOPBACK.has(oName) && LOOPBACK.has(name));
180
202
  }
181
203
  catch {
182
204
  return false;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { HELP, runPlayCli, type PlayCliIo } from "./cli.js";
1
+ export { COMMANDS_HELP, HELP, runKitPlayCli, runPlayCli, runTestCli, TEST_HELP, type PlayCliIo } from "./cli.js";
2
+ export { check, summary, type CheckOptions, type CheckReport, type CheckResult } from "./check.js";
2
3
  export { ALLOWED_IMPORT, play, PlayError, readGameFolder, type GameFolder, type PlayOptions, type Playing } from "./play.js";
3
4
  export { TEMPLATES, templateFor, type TemplateEntry, type TemplateId } from "./templates.js";
4
5
  export { namesModule, parseNames, type Names } from "./names.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- export { HELP, runPlayCli } from "./cli.js";
1
+ export { COMMANDS_HELP, HELP, runKitPlayCli, runPlayCli, runTestCli, TEST_HELP } from "./cli.js";
2
+ export { check, summary } from "./check.js";
2
3
  export { ALLOWED_IMPORT, play, PlayError, readGameFolder } from "./play.js";
3
4
  export { TEMPLATES, templateFor } from "./templates.js";
4
5
  export { namesModule, parseNames } from "./names.js";
package/dist/play.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { networkInterfaces } from "node:os";
1
2
  import { type Names } from "./names.js";
2
3
  import { type TemplateEntry } from "./templates.js";
3
4
  /** The only import a game module may make (the studio's rule, kept here so a game that plays locally plays in the studio). */
@@ -27,12 +28,24 @@ export declare function readGameFolder(dir: string): GameFolder;
27
28
  export declare function templateDir(t: TemplateEntry, from: string): string;
28
29
  /** The built asset library shipped in @netforge/asset-library-files, or null when it is not installed. */
29
30
  export declare function libraryDir(from: string): string | null;
31
+ /**
32
+ * The page link for a host base URL. The template clients connect to the
33
+ * room named by ?server= (without it they fall back to their dev port), so
34
+ * every link carries the server it was served from.
35
+ */
36
+ export declare function playLink(base: string, libraryUrl: string | null): string;
37
+ /** Links other devices can open when the host listens on every interface: one per IPv4 network address. */
38
+ export declare function lanLinks(host: string, port: number, interfaces: ReturnType<typeof networkInterfaces>, libraryUrl: string | null): string[];
30
39
  /** Builds the template client with the game module and labels swapped in. */
31
40
  export declare function buildClient(g: GameFolder, tplDir: string, outDir: string, cacheDir: string): Promise<void>;
41
+ /** The esbuild plugin that puts the game folder's src/game.ts in place of the template's and holds it to ALLOWED_IMPORT. */
42
+ export declare function gameSwap(manifest: string, gameFile: string, tplDir: string): import("esbuild").Plugin;
32
43
  /** Bundles the local host with the game module (one copy of the engine and the sim). Returns the bundle path. */
33
44
  export declare function bundleHost(g: GameFolder, tplDir: string, outFile: string): Promise<string>;
34
45
  export interface Playing {
35
46
  readonly url: string;
47
+ /** Links for other devices on the network (with --host 0.0.0.0), each carrying its own ?server=. */
48
+ readonly lan: readonly string[];
36
49
  close(): Promise<void>;
37
50
  }
38
51
  export declare function play(o: PlayOptions): Promise<Playing>;
package/dist/play.js CHANGED
@@ -14,6 +14,7 @@ import { createRequire } from "node:module";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import { spawn } from "node:child_process";
17
+ import { networkInterfaces } from "node:os";
17
18
  import { namesModule, parseNames } from "./names.js";
18
19
  import { templateFor } from "./templates.js";
19
20
  /** The only import a game module may make (the studio's rule, kept here so a game that plays locally plays in the studio). */
@@ -78,6 +79,27 @@ export function libraryDir(from) {
78
79
  return null;
79
80
  }
80
81
  }
82
+ /**
83
+ * The page link for a host base URL. The template clients connect to the
84
+ * room named by ?server= (without it they fall back to their dev port), so
85
+ * every link carries the server it was served from.
86
+ */
87
+ export function playLink(base, libraryUrl) {
88
+ const params = new URLSearchParams({ server: base });
89
+ if (libraryUrl)
90
+ params.set("library", libraryUrl);
91
+ return `${base}/?${params.toString()}`;
92
+ }
93
+ /** Links other devices can open when the host listens on every interface: one per IPv4 network address. */
94
+ export function lanLinks(host, port, interfaces, libraryUrl) {
95
+ const wildcard = host === "0.0.0.0" || host === "::";
96
+ const addrs = wildcard
97
+ ? Object.values(interfaces).flatMap((list) => (list ?? []).flatMap(({ family, internal, address }) => (family === "IPv4" && !internal ? [address] : [])))
98
+ : host !== "127.0.0.1" && host !== "localhost" && host !== "::1" && /^\d+\.\d+\.\d+\.\d+$/.test(host)
99
+ ? [host]
100
+ : [];
101
+ return [...new Set(addrs)].map((a) => playLink(`http://${a}:${port}`, libraryUrl));
102
+ }
81
103
  /** Builds the template client with the game module and labels swapped in. */
82
104
  export async function buildClient(g, tplDir, outDir, cacheDir) {
83
105
  const vite = await import("vite");
@@ -123,14 +145,9 @@ export async function buildClient(g, tplDir, outDir, cacheDir) {
123
145
  build: { outDir, emptyOutDir: true, target: "es2022", chunkSizeWarningLimit: 16384, reportCompressedSize: false },
124
146
  });
125
147
  }
126
- /** Bundles the local host with the game module (one copy of the engine and the sim). Returns the bundle path. */
127
- export async function bundleHost(g, tplDir, outFile) {
128
- const esbuild = await import("esbuild");
129
- const manifest = realpathSync(join(tplDir, "src", "manifest.ts"));
130
- const gameFile = realpathSync(g.gameFile);
131
- const here = dirname(fileURLToPath(import.meta.url));
132
- const entryFile = existsSync(join(here, "host", "entry.js")) ? join(here, "host", "entry.js") : join(here, "host", "entry.ts");
133
- const swap = {
148
+ /** The esbuild plugin that puts the game folder's src/game.ts in place of the template's and holds it to ALLOWED_IMPORT. */
149
+ export function gameSwap(manifest, gameFile, tplDir) {
150
+ return {
134
151
  name: "kit-play-swap",
135
152
  setup(b) {
136
153
  b.onResolve({ filter: /^\.\/game\.(js|ts)$/ }, (args) => (args.importer === manifest ? { path: gameFile } : undefined));
@@ -139,10 +156,19 @@ export async function bundleHost(g, tplDir, outFile) {
139
156
  return undefined;
140
157
  if (args.path === ALLOWED_IMPORT)
141
158
  return b.resolve(args.path, { resolveDir: join(tplDir, "src"), kind: args.kind });
142
- return { errors: [{ text: `src/game.ts may import only "${ALLOWED_IMPORT}"` }] };
159
+ return { errors: [{ text: `src/game.ts may import only "${ALLOWED_IMPORT}", not "${args.path}"` }] };
143
160
  });
144
161
  },
145
162
  };
163
+ }
164
+ /** Bundles the local host with the game module (one copy of the engine and the sim). Returns the bundle path. */
165
+ export async function bundleHost(g, tplDir, outFile) {
166
+ const esbuild = await import("esbuild");
167
+ const manifest = realpathSync(join(tplDir, "src", "manifest.ts"));
168
+ const gameFile = realpathSync(g.gameFile);
169
+ const here = dirname(fileURLToPath(import.meta.url));
170
+ const entryFile = existsSync(join(here, "host", "entry.js")) ? join(here, "host", "entry.js") : join(here, "host", "entry.ts");
171
+ const swap = gameSwap(manifest, gameFile, tplDir);
146
172
  const contents = [`export { startHost } from ${JSON.stringify(entryFile)};`, `export * as template from ${JSON.stringify(manifest)};`].join("\n");
147
173
  const out = await esbuild.build({
148
174
  stdin: { contents, resolveDir: here, sourcefile: "kit-play-host.js", loader: "js" },
@@ -195,15 +221,17 @@ export async function play(o) {
195
221
  clientDir: clientOut,
196
222
  libraryDir: lib,
197
223
  });
198
- const params = new URLSearchParams({ server: running.url });
199
- if (o.libraryUrl)
200
- params.set("library", o.libraryUrl);
201
- const url = `${running.url}/?${params.toString()}`;
224
+ const url = playLink(running.url, o.libraryUrl);
225
+ const lan = lanLinks(o.host, o.port, networkInterfaces(), o.libraryUrl);
202
226
  o.log(`Playing locally with play money at ${url}`);
227
+ for (const l of lan)
228
+ o.log(`On your network (phones, other computers): ${l}`);
229
+ if (lan.length === 0 && o.host !== "127.0.0.1" && o.host !== "localhost" && o.host !== "::1")
230
+ o.log(`Listening on ${o.host}: share a link of the form http://<this computer's address>:${o.port}/?server=http://<this computer's address>:${o.port}`);
203
231
  o.log("Open the link in more tabs or windows for more players. Ctrl+C stops.");
204
232
  o.log("kit play runs the game's code on this computer with your permissions: play only games whose code you trust.");
205
233
  if (o.open)
206
234
  for (let i = 0; i < Math.max(1, o.players); i++)
207
235
  openBrowser(url);
208
- return { url, close: running.close };
236
+ return { url, lan, close: running.close };
209
237
  }
package/llms.txt ADDED
@@ -0,0 +1,41 @@
1
+ # @netforge/kit-play
2
+
3
+ > kit play runs a Net Forge game on your own computer with play money. Inside a game folder (netnet.json and src/game.ts) it builds the template's browser client with that game module, starts a local room host for the same module, and opens http://127.0.0.1:4663. No wallet, no chain, no Net Forge servers; earn rules fire and pay nothing.
4
+
5
+ ## Run a game
6
+
7
+ - Install: `npm install @netforge/kit-play @netforge/engine` in the game folder (Node 20 or later).
8
+ - Run: `npx kit-play [dir]`, or `kit play` through @netforge/kit. It exits on Ctrl+C.
9
+ - The folder needs netnet.json with `{"template":"<id>"}` and src/game.ts. src/names.json (title and labels) is optional.
10
+ - Template ids: survivor, arena, tower-defense, dungeon, idle, platformer, cards, collector, racer, party.
11
+ - Start from a template: copy node_modules/@netforge/template-<name>/src/game.ts to src/game.ts (the package names are template-survivor, template-arena, template-tower, template-dungeon, template-idle, template-platformer, template-cards, template-collector, template-racer, template-party).
12
+ - The build lands in .netnet/play/ inside the game folder.
13
+
14
+ ## Flags
15
+
16
+ - `--port <n>`: the page and the room on one port (default 4663).
17
+ - `--host <addr>`: interface to listen on (default 127.0.0.1; 0.0.0.0 lets devices on your network join).
18
+ - `--players <n>`: browser tabs to open, one per local player (1 to 16, default 1).
19
+ - `--no-open`: print the link instead of opening a browser.
20
+ - `--library-url <url>`: load the asset library from an http(s) mirror instead of the installed @netforge/asset-library-files.
21
+
22
+ ## Programmatic API
23
+
24
+ - `play({ dir, port, host, open, players, libraryUrl, log })` resolves to `{ url, close() }`.
25
+ - `readGameFolder(dir)` checks a folder and throws `PlayError` with a readable message (no netnet.json, unknown template, missing src/game.ts, bad names.json).
26
+ - `runPlayCli(argv, io)` is the CLI; `HELP` is its help text.
27
+ - `TEMPLATES` and `templateFor(id)` describe the runnable templates.
28
+ - `ALLOWED_IMPORT` is "@netforge/engine", the only import a game module may have.
29
+
30
+ ## Rules for agents
31
+
32
+ - src/game.ts imports only @netforge/engine, the same rule the Net Forge studio enforces.
33
+ - Do not add renderers, netcode, physics or asset URLs to a game; the template draws and runs everything.
34
+ - kit play runs the game's code with the user's permissions. It is not a sandbox.
35
+ - Validate and playtest before playing: the MCP tools netforge_validate_game and netforge_run_playtest, or engine.test.run from @netforge/engine. netforge_play_locally runs kit play for an agent.
36
+
37
+ ## Docs
38
+
39
+ - https://forge.netnet.capital/docs/packages/kit-play
40
+ - MCP server: `npx -y @netforge/mcp` (package @netforge/mcp, bin netforge-mcp). Tools: netforge_list_templates, netforge_create_game, netforge_search_docs, netforge_explain_api, netforge_validate_game, netforge_run_playtest, netforge_play_locally.
41
+ - Agent setup in a project: `npx netforge setup agent`.
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@netforge/kit-play",
3
- "version": "0.1.0",
4
- "description": "kit play: run a game downloaded from Net Forge on your own computer with play money. Builds the template client with your game module and runs a local room host from the engine's open simulation. No wallet, no chain, no Net Forge servers.",
3
+ "version": "0.2.0",
4
+ "description": "Make, test and run Net Forge games on your own computer with play money: start a game from a template, run playtest bots, and play it in the browser with a local room host, with no wallet, chain or servers.",
5
+ "keywords": [
6
+ "game-engine",
7
+ "multiplayer",
8
+ "browser-game",
9
+ "game-server",
10
+ "websocket",
11
+ "game-template",
12
+ "threejs",
13
+ "typescript",
14
+ "gamedev",
15
+ "ai-agents",
16
+ "mcp",
17
+ "vibe-coding",
18
+ "net-forge"
19
+ ],
5
20
  "license": "MIT",
6
21
  "type": "module",
7
22
  "main": "./dist/index.js",
@@ -11,7 +26,8 @@
11
26
  "types": "./dist/index.d.ts",
12
27
  "default": "./dist/index.js"
13
28
  },
14
- "./package.json": "./package.json"
29
+ "./package.json": "./package.json",
30
+ "./llms.txt": "./llms.txt"
15
31
  },
16
32
  "bin": {
17
33
  "kit-play": "./dist/cli.js"
@@ -20,24 +36,22 @@
20
36
  "dist",
21
37
  "README.md",
22
38
  "LICENSE",
23
- "NOTICE"
39
+ "NOTICE",
40
+ "llms.txt"
24
41
  ],
42
+ "sideEffects": false,
25
43
  "publishConfig": {
26
44
  "access": "public"
27
45
  },
46
+ "homepage": "https://forge.netnet.capital/docs/packages/kit-play",
47
+ "bugs": {
48
+ "url": "https://github.com/netnetdev/net-forge/issues"
49
+ },
28
50
  "repository": {
29
51
  "type": "git",
30
52
  "url": "git+https://github.com/netnetdev/net-forge.git",
31
53
  "directory": "packages/kit-play"
32
54
  },
33
- "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/kit-play#readme",
34
- "keywords": [
35
- "netnet",
36
- "net-forge",
37
- "game",
38
- "local",
39
- "three.js"
40
- ],
41
55
  "engines": {
42
56
  "node": ">=20"
43
57
  },
@@ -46,30 +60,28 @@
46
60
  "@colyseus/ws-transport": "0.18.4",
47
61
  "esbuild": "0.25.12",
48
62
  "vite": "6.4.3",
49
- "@netforge/asset-library": "0.1.0",
50
- "@netforge/engine": "0.1.0",
51
- "@netforge/engine-sim": "0.1.0",
52
- "@netforge/asset-library-files": "0.1.0",
53
- "@netforge/template-arena": "0.1.0",
54
- "@netforge/tabletop": "0.1.0",
55
- "@netforge/template-cards": "0.1.0",
56
- "@netforge/template-collector": "0.1.0",
57
- "@netforge/template-idle": "0.1.0",
58
- "@netforge/template-dungeon": "0.1.0",
59
- "@netforge/template-party": "0.1.0",
60
- "@netforge/template-platformer": "0.1.0",
61
- "@netforge/template-racer": "0.1.0",
62
- "@netforge/template-tower": "0.1.0",
63
- "@netforge/template-survivor": "0.1.0"
63
+ "@netforge/engine-sim": "0.2.0",
64
+ "@netforge/asset-library": "0.2.0",
65
+ "@netforge/tabletop": "0.2.0",
66
+ "@netforge/asset-library-files": "0.2.0",
67
+ "@netforge/template-arena": "0.2.0",
68
+ "@netforge/engine": "0.2.0",
69
+ "@netforge/template-cards": "0.2.0",
70
+ "@netforge/template-dungeon": "0.2.0",
71
+ "@netforge/template-idle": "0.2.0",
72
+ "@netforge/template-collector": "0.2.0",
73
+ "@netforge/template-party": "0.2.0",
74
+ "@netforge/template-platformer": "0.2.0",
75
+ "@netforge/template-survivor": "0.2.0",
76
+ "@netforge/template-tower": "0.2.0",
77
+ "create-netforge": "0.2.0",
78
+ "@netforge/template-racer": "0.2.0"
64
79
  },
65
80
  "devDependencies": {
66
81
  "@types/node": "22.20.1",
67
82
  "typescript": "5.8.3",
68
83
  "vitest": "3.2.7"
69
84
  },
70
- "bugs": {
71
- "url": "https://github.com/netnetdev/net-forge/issues"
72
- },
73
85
  "scripts": {
74
86
  "build": "node ../engine/scripts/fresh-dist.mjs && tsc -p tsconfig.json",
75
87
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",