@joystack/cli 0.1.46 → 0.1.48
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/CHANGELOG.md +50 -0
- package/README.md +3 -2
- package/bin/joystack.mjs +11 -4
- package/lib/create.mjs +7 -1
- package/lib/dev-log.mjs +48 -0
- package/lib/dev.mjs +13 -3
- package/lib/freeze.mjs +56 -1
- package/lib/game-files.mjs +17 -0
- package/lib/publish.mjs +10 -1
- package/lib/setup-guide.md +122 -0
- package/lib/setup.mjs +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,56 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.48] - 2026-10-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `joystack publish --open` makes a copy published with `--slug` open to
|
|
22
|
+
anyone with a Joystack account. Without it, a `--slug` copy (an A/B copy, a
|
|
23
|
+
review build) opens only for accounts invited to Joystack.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Your game at its own address (`joystack publish` with no `--slug`, or
|
|
28
|
+
`--slug` set to the game's own name) is open to anyone with a Joystack
|
|
29
|
+
account, invited or not. A player who is not signed in is asked to sign in
|
|
30
|
+
first. A game published before this version opens only for invited accounts
|
|
31
|
+
until you publish it again.
|
|
32
|
+
|
|
33
|
+
- `joystack dev` reloads the game only when one of the game's own files
|
|
34
|
+
changes: the files `joystack publish` would upload, plus `package.json`.
|
|
35
|
+
Notes, tests, agent instructions and anything your `.gitignore` leaves out
|
|
36
|
+
no longer reload it. A log you write into the game folder used to reload
|
|
37
|
+
the game over and over until the page stopped loading; with `*.log` in
|
|
38
|
+
`.gitignore` (new games have it) that can no longer happen.
|
|
39
|
+
- `joystack dev` writes everything it prints to `.joystack/dev/last.log` as
|
|
40
|
+
well, and names that file when it starts, so its output can be read later
|
|
41
|
+
without sending it to a file of your own.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Sessions recorded under `joystack dev` carry the build of the game they ran
|
|
46
|
+
on. They were stamped with an empty build, because the copy `joystack dev`
|
|
47
|
+
serves sits in the ignored `.joystack/` folder and every file in it read as
|
|
48
|
+
ignored.
|
|
49
|
+
- A game folder inside a larger git repository that ignores that folder is
|
|
50
|
+
published whole. Every file in it used to read as ignored.
|
|
51
|
+
- `joystack create` gives a new game an exact Playwright version (1.64.0)
|
|
52
|
+
instead of "1.61.1 or any newer 1.x". Every game made on this CLI gets the
|
|
53
|
+
same Playwright and Chromium, and creating a game no longer downloads a new
|
|
54
|
+
Chromium each time Playwright publishes a release. Games you already have
|
|
55
|
+
keep the Playwright they have.
|
|
56
|
+
|
|
57
|
+
## [0.1.47] - 2026-10-09
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- `joystack setup` prints the guide an AI agent follows to make your first
|
|
62
|
+
game: ask what to call it, run `create`, then hand off into the new folder.
|
|
63
|
+
It works from any folder, before you have signed in, so the way to start is
|
|
64
|
+
now to ask your agent to run `npx -y joystack@latest setup` and follow what
|
|
65
|
+
it prints.
|
|
66
|
+
|
|
17
67
|
## [0.1.46] - 2026-10-09
|
|
18
68
|
|
|
19
69
|
### Changed
|
package/README.md
CHANGED
|
@@ -9,8 +9,9 @@ cd my-game
|
|
|
9
9
|
npx joystack dev
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
New here?
|
|
13
|
-
|
|
12
|
+
New here? Ask your AI agent to run **`npx -y joystack@latest setup`** and follow
|
|
13
|
+
what it prints. It asks what to call your game, creates the folder, and tells
|
|
14
|
+
you how to carry on from inside it.
|
|
14
15
|
|
|
15
16
|
## License
|
|
16
17
|
|
package/bin/joystack.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { lane } from '../lib/lane.mjs';
|
|
|
9
9
|
import { login, logout, whoami } from '../lib/login.mjs';
|
|
10
10
|
import { publish } from '../lib/publish.mjs';
|
|
11
11
|
import { create } from '../lib/create.mjs';
|
|
12
|
+
import { setupGuide } from '../lib/setup.mjs';
|
|
12
13
|
import { parseTestArgs, TEST_HELP } from '../lib/test-policy.mjs';
|
|
13
14
|
import { testCmd } from '../lib/test-cmd.mjs';
|
|
14
15
|
import { lintCmd } from '../lib/lint.mjs';
|
|
@@ -28,6 +29,7 @@ function flag(name) {
|
|
|
28
29
|
const HELP = `joystack — build and run Joystack games
|
|
29
30
|
|
|
30
31
|
Usage:
|
|
32
|
+
joystack setup The guide an AI agent follows to make your first game folder
|
|
31
33
|
joystack create <name> Bootstrap a new game folder (multiplayer works out of the box)
|
|
32
34
|
joystack dev [--port <n>] Run the full dev stack (game + multiplayer + services)
|
|
33
35
|
joystack dev --share ...and print a public link testers can play (needs cloudflared)
|
|
@@ -44,9 +46,11 @@ Usage:
|
|
|
44
46
|
joystack changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
45
47
|
joystack update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
|
|
46
48
|
joystack model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
|
|
47
|
-
joystack publish [--slug <s>] [--dev-tools]
|
|
48
|
-
Publish this game to <slug>.joystack.games
|
|
49
|
-
--
|
|
49
|
+
joystack publish [--slug <s>] [--dev-tools] [--open]
|
|
50
|
+
Publish this game to <slug>.joystack.games, open to anyone with a
|
|
51
|
+
Joystack account (--slug for an A/B copy, open only to invited
|
|
52
|
+
accounts unless --open; --dev-tools makes that copy a review build
|
|
53
|
+
with Tweaks on)
|
|
50
54
|
joystack build-id The id of this game's code as it is now, with its git commit
|
|
51
55
|
joystack replay list Every recorded session: when, how long, on what device, which build
|
|
52
56
|
joystack replay export [<id>] Turn a recorded session into tables you can query (Parquet)
|
|
@@ -190,6 +194,9 @@ try {
|
|
|
190
194
|
console.log(`\nWork in it:\n cd ${dir} && npx joystack dev\n`);
|
|
191
195
|
break;
|
|
192
196
|
}
|
|
197
|
+
case 'setup':
|
|
198
|
+
process.stdout.write(setupGuide());
|
|
199
|
+
break;
|
|
193
200
|
case 'create':
|
|
194
201
|
await create({ name: rest.find((a) => !a.startsWith('--')) });
|
|
195
202
|
break;
|
|
@@ -265,7 +272,7 @@ try {
|
|
|
265
272
|
break;
|
|
266
273
|
}
|
|
267
274
|
case 'publish':
|
|
268
|
-
await publish({ slug: flag('slug'), devTools: rest.includes('--dev-tools') });
|
|
275
|
+
await publish({ slug: flag('slug'), devTools: rest.includes('--dev-tools'), open: rest.includes('--open') });
|
|
269
276
|
break;
|
|
270
277
|
case 'feedback':
|
|
271
278
|
// Send first, then sync: a report filed this run is already stamped with
|
package/lib/create.mjs
CHANGED
|
@@ -65,7 +65,13 @@ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
|
|
|
65
65
|
// devDependencies and the Chromium binary is fetched at create time — a
|
|
66
66
|
// surprise "npm i -D playwright" + browser download mid-build is exactly
|
|
67
67
|
// what create-time provisioning exists to prevent.
|
|
68
|
-
|
|
68
|
+
//
|
|
69
|
+
// An exact version, so a game's Playwright, and the Chromium build it needs,
|
|
70
|
+
// changes only when this line does, never when Playwright publishes a release.
|
|
71
|
+
// The maker's machine image carries the Chromium for this version
|
|
72
|
+
// (maker/container/Dockerfile): changing it means rebuilding and redeploying
|
|
73
|
+
// the maker, or every game the maker creates downloads its own Chromium.
|
|
74
|
+
const PLAYWRIGHT_SPEC = '1.64.0';
|
|
69
75
|
|
|
70
76
|
// The Joystack rules ship as an ESLint config (the scaffold's eslint.config.js,
|
|
71
77
|
// importing the rules from the installed engine), so they squiggle in the
|
package/lib/dev-log.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// `.joystack/dev/last.log`: everything an interactive `joystack dev` prints,
|
|
2
|
+
// written by `joystack dev` itself.
|
|
3
|
+
//
|
|
4
|
+
// An agent runs `joystack dev` in the background and needs its output later.
|
|
5
|
+
// Sending that output to a file of its own puts the file wherever the agent
|
|
6
|
+
// chose, often inside the game folder, where the file is mistaken for part of
|
|
7
|
+
// the game. This file is inside the game's state folder, which nothing treats
|
|
8
|
+
// as the game: no version, no publish, no commit. The game's AGENTS.md tells
|
|
9
|
+
// agents to read it.
|
|
10
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { findProject } from './project.mjs';
|
|
13
|
+
import { stateDir } from './legacy-names.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Starts a fresh log for the game around `cwd`. Returns null when `cwd` is not
|
|
17
|
+
* in a game (startDev reports that) or the log cannot be written; a dev
|
|
18
|
+
* server that cannot keep a log still runs.
|
|
19
|
+
* @returns {{ path: string, write: (line: string) => void, tee: (log: (line: string) => void) => (line: string) => void } | null}
|
|
20
|
+
*/
|
|
21
|
+
export function openDevLog(cwd) {
|
|
22
|
+
let path;
|
|
23
|
+
try {
|
|
24
|
+
const dir = join(stateDir(findProject(cwd).dir), 'dev');
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
path = join(dir, 'last.log');
|
|
27
|
+
writeFileSync(path, '');
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
let broken = false;
|
|
32
|
+
const write = (line) => {
|
|
33
|
+
if (broken) return;
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(path, `${line}\n`);
|
|
36
|
+
} catch {
|
|
37
|
+
broken = true;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
path,
|
|
42
|
+
write,
|
|
43
|
+
tee: (log) => (line) => {
|
|
44
|
+
log(line);
|
|
45
|
+
write(String(line));
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/lib/dev.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
|
|
|
23
23
|
import { createLiveVersions, snapshotFiles } from './live-versions.mjs';
|
|
24
24
|
import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
|
|
25
25
|
import { openTunnel, shareBanner, engineCanShare } from './share.mjs';
|
|
26
|
-
import {
|
|
26
|
+
import { createVersionFiles, freezeGame } from './freeze.mjs';
|
|
27
|
+
import { openDevLog } from './dev-log.mjs';
|
|
27
28
|
import { STATE_DIR_NAMES, readEnv, stateDir } from './legacy-names.mjs';
|
|
28
29
|
|
|
29
30
|
// The route a room under `joystack dev` answers with the name of the build it
|
|
@@ -84,6 +85,12 @@ export async function waitForRoomServer(port, { timeoutMs = 30_000, intervalMs =
|
|
|
84
85
|
// `share` opens a public link to the stack for testers (share.mjs); `openShare`
|
|
85
86
|
// replaces the tunnel for tests.
|
|
86
87
|
export async function dev(opts = {}) {
|
|
88
|
+
// An interactive `joystack dev` (watch on, the default) also writes
|
|
89
|
+
// everything it prints to .joystack/dev/last.log, so an agent that started
|
|
90
|
+
// it in the background reads its output there instead of sending it to a
|
|
91
|
+
// file of its own (dev-log.mjs).
|
|
92
|
+
const devLog = opts.watch === false ? null : openDevLog(opts.cwd ?? process.cwd());
|
|
93
|
+
if (devLog) opts = { ...opts, log: devLog.tee(opts.log ?? console.log), devLogPath: devLog.path };
|
|
87
94
|
// Whatever startup has made so far, undone if a later step throws: a room
|
|
88
95
|
// build that fails on the creator's code, a port already taken, a tunnel
|
|
89
96
|
// that never opens. A frozen copy of the game is among it.
|
|
@@ -94,6 +101,7 @@ export async function dev(opts = {}) {
|
|
|
94
101
|
for (const fn of undo.reverse()) {
|
|
95
102
|
try { fn(); } catch { /* the original error is the one to report */ }
|
|
96
103
|
}
|
|
104
|
+
devLog?.write(`joystack dev: ${err?.message ?? err}`);
|
|
97
105
|
throw err;
|
|
98
106
|
}
|
|
99
107
|
}
|
|
@@ -102,7 +110,7 @@ export async function dev(opts = {}) {
|
|
|
102
110
|
// (agent-session.mjs). Only the interactive `joystack dev` and `joystack assets`
|
|
103
111
|
// commands pass one; a stack started by `joystack test` or any other caller has
|
|
104
112
|
// none, so no test run can wake the agent that started it.
|
|
105
|
-
async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null } = {}, onFailure) {
|
|
113
|
+
async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null, devLogPath = null } = {}, onFailure) {
|
|
106
114
|
const project = findProject(cwd);
|
|
107
115
|
// Q4 revision: the engine is not an npm dependency — install/refresh it from
|
|
108
116
|
// the platform's login-gated registry (no-op when the pinned version is in).
|
|
@@ -204,8 +212,9 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
204
212
|
};
|
|
205
213
|
// What a version is made from: the game's files, and the engine's, which the
|
|
206
214
|
// room bundle includes. Engine files are served as they are, not copied.
|
|
215
|
+
const gameFiles = createVersionFiles(project.dir);
|
|
207
216
|
const sourceFiles = () => {
|
|
208
|
-
const files =
|
|
217
|
+
const files = gameFiles();
|
|
209
218
|
for (const [k, v] of snapshotFiles([engine.sharedDir], (name) => name.startsWith('.') || name === 'node_modules')) files.set(k, v);
|
|
210
219
|
return files;
|
|
211
220
|
};
|
|
@@ -562,6 +571,7 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
562
571
|
if (hasDeclaredAssets(project.dir)) {
|
|
563
572
|
log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
|
|
564
573
|
}
|
|
574
|
+
if (devLogPath) log(` 📄 ${relative(project.dir, devLogPath).split(sep).join('/')} (everything this server prints, for reading later)`);
|
|
565
575
|
if (share) {
|
|
566
576
|
log('');
|
|
567
577
|
log('→ share opening a public link (cloudflared)…');
|
package/lib/freeze.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import { constants, copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
12
|
-
import {
|
|
12
|
+
import { statSync } from 'node:fs';
|
|
13
|
+
import { collectGameFiles, gitIgnored, isGameFilePath } from './game-files.mjs';
|
|
13
14
|
import { gameBuild } from './build-id.mjs';
|
|
14
15
|
import { snapshotFiles } from './live-versions.mjs';
|
|
15
16
|
import { STATE_DIR_NAMES, stateDir } from './legacy-names.mjs';
|
|
@@ -39,6 +40,60 @@ export function frozenFiles(dir) {
|
|
|
39
40
|
return snapshotFiles([dir], (name, depth) => depth === 0 && LEFT_OUT.has(name), { hashBelow: 256 * 1024 });
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Files a version also needs that are not part of the published game:
|
|
44
|
+
// package.json says which framework the game is built on and which engine.
|
|
45
|
+
const BUILD_FILES = new Set(['package.json']);
|
|
46
|
+
const IGNORE_MAX_AGE_MS = 5000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The files a version of the game is made from, as path → `mtime:size`: the
|
|
50
|
+
* game's own files by the definition `joystack publish` uses (game-files.mjs),
|
|
51
|
+
* which leaves out notes, tests, agent instructions and everything the game's
|
|
52
|
+
* .gitignore excludes, plus BUILD_FILES. A change to any other file in the
|
|
53
|
+
* folder (a log, a scratch file, a test) is not a change to the game and does
|
|
54
|
+
* not make a version.
|
|
55
|
+
*
|
|
56
|
+
* Asking git which paths the .gitignore excludes is the costly part, so the
|
|
57
|
+
* answer is kept until the list of paths or an ignore file in the game
|
|
58
|
+
* changes, and for at most IGNORE_MAX_AGE_MS, which bounds how long a change
|
|
59
|
+
* git reads from elsewhere (a .gitignore above the game, the global excludes
|
|
60
|
+
* file, `git add -f`) takes to apply. If git fails after the first answer, the
|
|
61
|
+
* last answer stands and git is asked again on the next look, so a passing
|
|
62
|
+
* git failure never stops the dev server.
|
|
63
|
+
*/
|
|
64
|
+
export function createVersionFiles(dir) {
|
|
65
|
+
let key = null;
|
|
66
|
+
let askedAt = 0;
|
|
67
|
+
let ignored = null;
|
|
68
|
+
const exclude = join(dir, '.git', 'info', 'exclude');
|
|
69
|
+
return () => {
|
|
70
|
+
const all = frozenFiles(dir);
|
|
71
|
+
const rels = new Map();
|
|
72
|
+
for (const path of all.keys()) rels.set(path, relative(dir, path).split(sep).join('/'));
|
|
73
|
+
const candidates = [...rels.values()].filter((rel) => BUILD_FILES.has(rel) || isGameFilePath(rel));
|
|
74
|
+
let excludeStamp = '';
|
|
75
|
+
try { excludeStamp = String(statSync(exclude).mtimeMs); } catch { /* none */ }
|
|
76
|
+
const ignoreFiles = [...all].filter(([path]) => basename(path) === '.gitignore').map(([, entry]) => entry);
|
|
77
|
+
const nextKey = `${candidates.join('\0')}|${ignoreFiles.join('|')}|${excludeStamp}`;
|
|
78
|
+
if (nextKey !== key || Date.now() - askedAt > IGNORE_MAX_AGE_MS) {
|
|
79
|
+
try {
|
|
80
|
+
ignored = gitIgnored(dir, candidates);
|
|
81
|
+
key = nextKey;
|
|
82
|
+
askedAt = Date.now();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (ignored === null) throw err;
|
|
85
|
+
key = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const out = new Map();
|
|
89
|
+
for (const [path, entry] of all) {
|
|
90
|
+
const rel = rels.get(path);
|
|
91
|
+
if ((BUILD_FILES.has(rel) || isGameFilePath(rel)) && !ignored.has(rel)) out.set(path, entry);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
/**
|
|
43
98
|
* Copies the game at `dir` into `.joystack/frozen/<pid>-<random>/<folder name>`.
|
|
44
99
|
* `build()` is the game's build as the copy has it, the one every session
|
package/lib/game-files.mjs
CHANGED
|
@@ -40,6 +40,12 @@ const SKIP_FILES = [
|
|
|
40
40
|
// files the .gitignore was written to keep out.
|
|
41
41
|
export function gitIgnored(dir, rels) {
|
|
42
42
|
if (!rels.length) return new Set();
|
|
43
|
+
// A folder that is itself ignored by an enclosing repository is not part of
|
|
44
|
+
// that repository, so no .gitignore is in force inside it: git would report
|
|
45
|
+
// every file under it as ignored. A copy of the game that `joystack dev`
|
|
46
|
+
// serves lives in the game's ignored .joystack/ folder, and a game folder can
|
|
47
|
+
// sit in an ignored folder of a larger repository; both keep all their files.
|
|
48
|
+
if (folderIgnored(dir)) return new Set();
|
|
43
49
|
try {
|
|
44
50
|
const out = execFileSync('git', ['check-ignore', '--stdin', '-z'], {
|
|
45
51
|
cwd: dir,
|
|
@@ -59,6 +65,17 @@ export function gitIgnored(dir, rels) {
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
function folderIgnored(dir) {
|
|
69
|
+
try {
|
|
70
|
+
execFileSync('git', ['check-ignore', '-q', '.'], { cwd: dir, stdio: 'ignore', timeout: 30_000 });
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
// Exit 1 (not ignored), 128 (no repository) or no git: the .gitignore, if
|
|
74
|
+
// any, is read as usual below.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
/**
|
|
63
80
|
* Whether a path, relative to the game folder with `/` separators, is one the
|
|
64
81
|
* walk below would take by its name alone — before the .gitignore check. A
|
package/lib/publish.mjs
CHANGED
|
@@ -41,6 +41,7 @@ export async function publish({
|
|
|
41
41
|
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
42
42
|
slug,
|
|
43
43
|
devTools = false,
|
|
44
|
+
open = false,
|
|
44
45
|
log = console.log,
|
|
45
46
|
} = {}) {
|
|
46
47
|
const project = findProject(cwd);
|
|
@@ -90,6 +91,14 @@ export async function publish({
|
|
|
90
91
|
// ensureEngine keeps the local install synced to the game's pin.
|
|
91
92
|
const engine = await ensureEngine(project.dir, { apiBase, log });
|
|
92
93
|
const targetSlug = slug ?? project.slug;
|
|
94
|
+
// Who may open the published game. The game under its own name is open to
|
|
95
|
+
// every signed-in Joystack account; a copy under another name (an A/B copy, a
|
|
96
|
+
// pull request's or a lane's review link) is open only to invited accounts,
|
|
97
|
+
// unless --open asks otherwise. Inside a lane the game's own name is that of
|
|
98
|
+
// the game the lane was opened from, since the lane folder is named after the
|
|
99
|
+
// lane.
|
|
100
|
+
const ownSlug = laneParentSlug(project.dir) ?? project.slug;
|
|
101
|
+
const openToAll = open || targetSlug === ownSlug;
|
|
93
102
|
|
|
94
103
|
const files = collectGameFiles(project.dir);
|
|
95
104
|
// The build id of the creator's own files, before the generated ones below
|
|
@@ -156,7 +165,7 @@ export async function publish({
|
|
|
156
165
|
}
|
|
157
166
|
}
|
|
158
167
|
|
|
159
|
-
const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}) };
|
|
168
|
+
const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}), open: openToAll };
|
|
160
169
|
for (const [path, bytes] of files) meta.files[path] = createHash('sha256').update(bytes).digest('hex');
|
|
161
170
|
|
|
162
171
|
// The platform only publishes framework-v2 games, and checks their code
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Make games on Joystack
|
|
2
|
+
|
|
3
|
+
Joystack is a place to make multiplayer browser games and play them with
|
|
4
|
+
friends; each game lives at its own address, `https://<name>.joystack.games/`.
|
|
5
|
+
Anyone can make one. You don't
|
|
6
|
+
need to know how to code: your AI coding agent does the technical work; you
|
|
7
|
+
describe the game you want and play-test it.
|
|
8
|
+
|
|
9
|
+
Joystack is **agent-agnostic**: it works with Claude Code, Gemini CLI, Codex,
|
|
10
|
+
opencode, or any agent that reads a project's instruction files. There is no
|
|
11
|
+
plugin or extension to install. Every game folder carries its own agent
|
|
12
|
+
instructions (`AGENTS.md`, with `CLAUDE.md` / `GEMINI.md` pointers, and skills
|
|
13
|
+
under `.claude/skills/`), so whatever tool you use picks them up.
|
|
14
|
+
|
|
15
|
+
This guide is what `npx -y joystack@latest setup` prints. It is written for
|
|
16
|
+
the AI agent: **if you are the agent, follow these steps.** If you are the
|
|
17
|
+
human, ask your agent to run that command and follow what it prints.
|
|
18
|
+
|
|
19
|
+
**Your whole job here is: create the folder, then hand off.** Two steps, and the
|
|
20
|
+
page ends. You are *not* going to build, run, or publish the game from where you
|
|
21
|
+
are now — and this page deliberately doesn't tell you how, because that isn't
|
|
22
|
+
your task and those instructions are not yours to hold.
|
|
23
|
+
|
|
24
|
+
**They live in the game folder.** The scaffold you're about to create carries its
|
|
25
|
+
own `AGENTS.md`, its own skills (`/build`, `/qa`, `/feedback`, …), its own
|
|
26
|
+
handbook, and the engine's craft docs. An agent picks all of that up **only when
|
|
27
|
+
its working directory is that folder**. That is the entire reason for the
|
|
28
|
+
handoff: not bureaucracy, but the fact that the knowledge for building the game
|
|
29
|
+
is *inside* the thing you're creating, and you can't reach it from out here.
|
|
30
|
+
|
|
31
|
+
## 1. Prerequisites
|
|
32
|
+
|
|
33
|
+
- Node.js 20.11+ (`node --version`; install from https://nodejs.org if missing).
|
|
34
|
+
- A Joystack account. You don't need one *before* you start — `create` signs the
|
|
35
|
+
human in as part of making the folder (step 2). It's free, and it takes a
|
|
36
|
+
click.
|
|
37
|
+
- Nothing else. The engine, its libraries, and the craft docs download during
|
|
38
|
+
`create`, into the game folder. There is no global install.
|
|
39
|
+
|
|
40
|
+
## 2. Create the game
|
|
41
|
+
|
|
42
|
+
Ask the human what to name the game. The name becomes both the folder and the
|
|
43
|
+
published URL slug, so it must be **lowercase letters, numbers and dashes**
|
|
44
|
+
(e.g. `neon-racer`). Don't invent one silently. It's theirs to choose.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx -y joystack@latest create <name>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This scaffolds `<name>/` as its own git repo: a working multiplayer game plus
|
|
51
|
+
all the agent instructions and the `/build` workflow, inside the folder.
|
|
52
|
+
|
|
53
|
+
**This command will pause and ask the human to sign in.** That is expected — not
|
|
54
|
+
a hang, and nothing has gone wrong. Joystack's engine downloads with the game, and
|
|
55
|
+
that download is tied to their account. What happens:
|
|
56
|
+
|
|
57
|
+
1. A browser window opens on the Joystack activation page.
|
|
58
|
+
2. The command prints a short code (like `WXYZ-1234`) and waits.
|
|
59
|
+
3. The human enters that code in the browser and approves.
|
|
60
|
+
4. `create` continues on its own.
|
|
61
|
+
|
|
62
|
+
**Tell them this is coming, before you run it** — a browser opening by itself and
|
|
63
|
+
a command that stops mid-way is alarming if nobody warned you. Say something
|
|
64
|
+
like: *"This will open your browser to sign you in to Joystack — enter the code it
|
|
65
|
+
shows and approve, and it'll carry on."* Then read them the code from the output.
|
|
66
|
+
|
|
67
|
+
If they close the window or it times out, nothing is left behind: `create`
|
|
68
|
+
removes the half-made folder so you can simply run it again.
|
|
69
|
+
|
|
70
|
+
Once signed in, this machine stays signed in — no other command in Joystack will
|
|
71
|
+
ask again.
|
|
72
|
+
|
|
73
|
+
## 3. Hand off — and stop
|
|
74
|
+
|
|
75
|
+
**Say this to the human, and end your turn:**
|
|
76
|
+
|
|
77
|
+
> "Your game is ready, in the folder `<name>`. Now open your agent again **with
|
|
78
|
+
> that folder as its working directory** — a fresh session inside the folder —
|
|
79
|
+
> and tell it what you want to build. Everything needed to make the game lives
|
|
80
|
+
> in there; I can't reach it from here."
|
|
81
|
+
|
|
82
|
+
Then stop. **Do not** `cd` in and keep working. **Do not** start the dev server,
|
|
83
|
+
install anything, write files into the new repo, take screenshots, or "just check
|
|
84
|
+
that it works" — it already works, and the folder ships with its own test that
|
|
85
|
+
the next session will run. Anything you do from out here is done *without* the
|
|
86
|
+
game's instructions, on a repo that isn't shaped for your current habits. That is
|
|
87
|
+
how creators end up with stray files in their first commit and confident, wrong
|
|
88
|
+
answers about their own game.
|
|
89
|
+
|
|
90
|
+
**If the human doesn't know how to do that** — and many won't, this is the one
|
|
91
|
+
genuinely technical moment in Joystack — walk them through it rather than doing it
|
|
92
|
+
for them. Explain it plainly:
|
|
93
|
+
|
|
94
|
+
> "Whatever you're using me through — a terminal, an editor, a desktop app —
|
|
95
|
+
> point it at the `<name>` folder and start a new chat there. It's the same as
|
|
96
|
+
> opening a project: the folder *is* the game, and it carries its own
|
|
97
|
+
> instructions for me. Once I'm in there I'll know how to run it, build it, test
|
|
98
|
+
> it and publish it — none of which I know from here."
|
|
99
|
+
|
|
100
|
+
If they're in a terminal, that's usually: close this session, `cd <name>`, and
|
|
101
|
+
start the agent again. If they're in an editor or a desktop app, it's "open
|
|
102
|
+
folder" / "open project" → `<name>` → new chat. Adapt it to their tool; you know
|
|
103
|
+
what they're using and this page doesn't.
|
|
104
|
+
|
|
105
|
+
That's the last step on this page. The next session takes it from there: it will
|
|
106
|
+
boot the game and build it with them. They're already signed in, and the engine
|
|
107
|
+
is already downloaded — so in there, `joystack dev` just runs.
|
|
108
|
+
|
|
109
|
+
## What Joystack provides
|
|
110
|
+
|
|
111
|
+
- **Multiplayer built in.** Rooms, presence, physics authority, identity.
|
|
112
|
+
Locally `joystack dev` runs the full stack; published games use the platform's.
|
|
113
|
+
- **The engine.** A versioned library of game components and craft docs,
|
|
114
|
+
downloaded per game and pinned in package.json's `joystack.engine`. Upgrade when
|
|
115
|
+
you choose.
|
|
116
|
+
- **Services.** LLM-driven NPCs and more, platform-authenticated. No API keys
|
|
117
|
+
to acquire, ever.
|
|
118
|
+
- **Hosting.** `<name>.joystack.games`, instantly, from `joystack publish`.
|
|
119
|
+
|
|
120
|
+
Your game's folder is yours: its own git repo, private if you like, hosted
|
|
121
|
+
wherever you like (or nowhere). Joystack never needs to see your source beyond
|
|
122
|
+
what you publish.
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `joystack setup`: the guide an AI agent follows to create a creator's first
|
|
2
|
+
// game folder and hand off into it. It ships inside this package, so an agent
|
|
3
|
+
// reads it with one command it can run from any folder, signed in or not, and
|
|
4
|
+
// the steps always match the `create` of the same release.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
export function setupGuide() {
|
|
8
|
+
return readFileSync(new URL('./setup-guide.md', import.meta.url), 'utf8');
|
|
9
|
+
}
|
package/package.json
CHANGED