@joystack/cli 0.1.45 → 0.1.47
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 +33 -0
- package/README.md +3 -2
- package/bin/joystack.mjs +5 -0
- package/lib/dev.mjs +268 -113
- package/lib/freeze.mjs +81 -25
- package/lib/live-versions.mjs +212 -0
- package/lib/setup-guide.md +122 -0
- package/lib/setup.mjs +9 -0
- package/lib/static-server.mjs +208 -110
- package/package.json +1 -1
- package/lib/room-reload.mjs +0 -168
package/lib/freeze.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
//
|
|
1
|
+
// A copy of the game as it is at one moment: one version of it, as `joystack
|
|
2
|
+
// dev` serves it (live-versions.mjs).
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
import { constants, cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
4
|
+
// Serving the game's own folder cannot give a page one version: the page
|
|
5
|
+
// fetches files for as long as it runs, while the room and the injected entity
|
|
6
|
+
// layout are built once, so an edit lands in some of them and not the others.
|
|
7
|
+
// A copy is served and built from instead, and replaced by a new copy, never
|
|
8
|
+
// edited.
|
|
9
|
+
import { constants, copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
|
-
import { basename, join } from 'node:path';
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
12
|
+
import { collectGameFiles } from './game-files.mjs';
|
|
12
13
|
import { gameBuild } from './build-id.mjs';
|
|
14
|
+
import { snapshotFiles } from './live-versions.mjs';
|
|
13
15
|
import { STATE_DIR_NAMES, stateDir } from './legacy-names.mjs';
|
|
14
16
|
|
|
15
17
|
// Left out of the copy: recordings and generated build output (.joystack), which
|
|
@@ -27,21 +29,39 @@ export function frozenRoot(gameDir) {
|
|
|
27
29
|
return join(stateDir(gameDir), 'frozen');
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
// The files a copy of the game at `dir` is taken from, as path → `mtime:size`.
|
|
33
|
+
//
|
|
34
|
+
// Small files also carry a hash of their content. On Windows a file's
|
|
35
|
+
// modified time moves in steps of about 15 ms, so a rewrite to the same size
|
|
36
|
+
// within one step would otherwise go unseen; large files are left to time and
|
|
37
|
+
// size, since reading them on every poll would cost more than it finds.
|
|
38
|
+
export function frozenFiles(dir) {
|
|
39
|
+
return snapshotFiles([dir], (name, depth) => depth === 0 && LEFT_OUT.has(name), { hashBelow: 256 * 1024 });
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Copies the game at `dir` into `.joystack/frozen/<pid>-<random>/<folder name>`.
|
|
32
|
-
* `build` is the game's build
|
|
33
|
-
* played on the copy is stamped with
|
|
34
|
-
*
|
|
35
|
-
* running it is swept by the
|
|
44
|
+
* `build()` is the game's build as the copy has it, the one every session
|
|
45
|
+
* played on the copy is stamped with; it is worked out the first time it is
|
|
46
|
+
* asked for. `remove()` deletes the copy; it also runs when the process exits
|
|
47
|
+
* normally, and a copy whose process died without running it is swept by the
|
|
48
|
+
* next freeze of the same game.
|
|
49
|
+
*
|
|
50
|
+
* `files` is the snapshot the copy is taken from (frozenFiles). With `from`, a
|
|
51
|
+
* previous copy and its snapshot, a file whose entry is the same in both is
|
|
52
|
+
* hard-linked to the previous copy's instead of copied, so a copy costs the
|
|
53
|
+
* files that changed. Copies are never written to after they are made, which
|
|
54
|
+
* is what makes sharing their files safe; the game's own files are always
|
|
55
|
+
* copied, never linked, since the game's are edited in place.
|
|
36
56
|
*
|
|
37
57
|
* Symlinks inside the game are copied as what they point to, so a linked
|
|
38
58
|
* folder is frozen with the rest.
|
|
39
59
|
* @param {string} dir
|
|
40
|
-
* @
|
|
60
|
+
* @param {{ files?: Map<string, string>, from?: { dir: string, files: Map<string, string> } }} [opts]
|
|
61
|
+
* @returns {{ dir: string, build: () => object, remove: () => void }}
|
|
41
62
|
*/
|
|
42
|
-
export function freezeGame(dir) {
|
|
63
|
+
export function freezeGame(dir, { files = null, from = null } = {}) {
|
|
43
64
|
sweepStale(dir);
|
|
44
|
-
const build = gameBuild(dir);
|
|
45
65
|
const run = join(frozenRoot(dir), `${process.pid}-${randomBytes(3).toString('hex')}`);
|
|
46
66
|
// The folder name is the game's slug unless package.json names one, so the
|
|
47
67
|
// copy keeps it.
|
|
@@ -50,21 +70,54 @@ export function freezeGame(dir) {
|
|
|
50
70
|
let removed = false;
|
|
51
71
|
const remove = () => {
|
|
52
72
|
if (removed) return;
|
|
53
|
-
removed = true;
|
|
54
|
-
process.off('exit', remove);
|
|
55
73
|
// The link first, on its own: on Windows it is a junction, and a recursive
|
|
56
74
|
// delete that followed it would empty the game's real node_modules.
|
|
57
75
|
try { unlinkSync(modulesLink); } catch { /* never made */ }
|
|
58
|
-
|
|
76
|
+
// On Windows a file a process still has open cannot be deleted, and a
|
|
77
|
+
// stopped room server lets go of its files a moment after it exits. A copy
|
|
78
|
+
// that still cannot be deleted is tried again when this process exits,
|
|
79
|
+
// and swept by the next `joystack dev` of the game after that.
|
|
80
|
+
try {
|
|
81
|
+
rmSync(run, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
removed = true;
|
|
86
|
+
process.off('exit', remove);
|
|
59
87
|
};
|
|
60
88
|
process.on('exit', remove);
|
|
61
89
|
try {
|
|
62
90
|
mkdirSync(copy, { recursive: true });
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
if (files) {
|
|
92
|
+
const made = new Set();
|
|
93
|
+
for (const [path, entry] of files) {
|
|
94
|
+
const rel = relative(dir, path);
|
|
95
|
+
if (rel.startsWith('..') || isAbsolute(rel)) continue;
|
|
96
|
+
// The snapshot also names files the copy leaves out: an engine
|
|
97
|
+
// installed in the game's node_modules is watched along with the game,
|
|
98
|
+
// and the copy reaches it through the node_modules link made below.
|
|
99
|
+
if (LEFT_OUT.has(rel.split(sep)[0])) continue;
|
|
100
|
+
const to = join(copy, rel);
|
|
101
|
+
const parent = dirname(to);
|
|
102
|
+
if (!made.has(parent)) {
|
|
103
|
+
mkdirSync(parent, { recursive: true });
|
|
104
|
+
made.add(parent);
|
|
105
|
+
}
|
|
106
|
+
if (from && from.files.get(path) === entry) {
|
|
107
|
+
try {
|
|
108
|
+
linkSync(join(from.dir, rel), to);
|
|
109
|
+
continue;
|
|
110
|
+
} catch { /* a disk without hard links, or the previous file is gone: copied below */ }
|
|
111
|
+
}
|
|
112
|
+
copyFileSync(path, to, constants.COPYFILE_FICLONE);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
// Entry by entry, because the copy lives inside the game (under
|
|
116
|
+
// .joystack), and a copy of a folder into its own subfolder is refused.
|
|
117
|
+
for (const entry of readdirSync(dir)) {
|
|
118
|
+
if (LEFT_OUT.has(entry)) continue;
|
|
119
|
+
cpSync(join(dir, entry), join(copy, entry), { recursive: true, dereference: true, mode: constants.COPYFILE_FICLONE });
|
|
120
|
+
}
|
|
68
121
|
}
|
|
69
122
|
const modules = join(dir, 'node_modules');
|
|
70
123
|
if (existsSync(modules)) symlinkSync(modules, modulesLink, process.platform === 'win32' ? 'junction' : 'dir');
|
|
@@ -72,7 +125,10 @@ export function freezeGame(dir) {
|
|
|
72
125
|
remove();
|
|
73
126
|
throw err;
|
|
74
127
|
}
|
|
75
|
-
|
|
128
|
+
// The id is the copy's files; the commit and whether the files differ from
|
|
129
|
+
// it are read from the game's folder when the build is first asked for.
|
|
130
|
+
let build = null;
|
|
131
|
+
return { dir: copy, build: () => (build ??= gameBuild(dir, { files: collectGameFiles(copy) })), remove };
|
|
76
132
|
}
|
|
77
133
|
|
|
78
134
|
// Deletes the copies of runs whose process no longer exists: a `joystack dev`
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The versions `joystack dev` serves.
|
|
2
|
+
//
|
|
3
|
+
// A version is a copy of the game taken at one moment (freeze.mjs), with the
|
|
4
|
+
// room and the entity layout built from that copy. The page, every file it
|
|
5
|
+
// fetches later and the room all come from one version, so a page never runs
|
|
6
|
+
// against files that changed under it.
|
|
7
|
+
//
|
|
8
|
+
// This module decides when a new version is made and keeps the state every
|
|
9
|
+
// page and the control endpoints report:
|
|
10
|
+
//
|
|
11
|
+
// state 'ready' the current version is served
|
|
12
|
+
// 'updating' a new version is being made; game pages show "Updating…"
|
|
13
|
+
// 'failed' the last attempt failed; game pages show `error`
|
|
14
|
+
// auto whether a version is made as soon as the files settle
|
|
15
|
+
// pending whether the files differ from the last version attempted
|
|
16
|
+
//
|
|
17
|
+
// The operating system says when a file in a watched folder changes, and a
|
|
18
|
+
// version is made once no change has arrived for `quietMs`. The events only
|
|
19
|
+
// say when to look: what a version is made from, and whether anything is
|
|
20
|
+
// pending, is always a fresh snapshot of the files. A slower poll covers
|
|
21
|
+
// changes the events miss (a folder the system cannot watch, an engine
|
|
22
|
+
// checkout); a poll counts the files as settled when it finds what the
|
|
23
|
+
// previous poll found.
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { readdirSync, readFileSync, statSync, watch as watchFolder } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
// Every file under each root, as path → `mtime:size`, with a hash of the
|
|
29
|
+
// content appended for files smaller than `hashBelow` bytes. `skip(name,
|
|
30
|
+
// depth)` leaves an entry out; depth 0 is a root's own entries.
|
|
31
|
+
export function snapshotFiles(roots, skip = () => false, { hashBelow = 0 } = {}) {
|
|
32
|
+
const out = new Map();
|
|
33
|
+
const walk = (dir, depth) => {
|
|
34
|
+
let names;
|
|
35
|
+
try {
|
|
36
|
+
names = readdirSync(dir);
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
if (skip(name, depth)) continue;
|
|
42
|
+
const p = join(dir, name);
|
|
43
|
+
let st;
|
|
44
|
+
try {
|
|
45
|
+
st = statSync(p);
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (st.isDirectory()) walk(p, depth + 1);
|
|
50
|
+
else out.set(p, `${st.mtimeMs}:${st.size}${st.size < hashBelow ? `:${contentHash(p)}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
for (const root of roots) walk(root, 0);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function contentHash(path) {
|
|
58
|
+
try {
|
|
59
|
+
return createHash('sha1').update(readFileSync(path)).digest('base64');
|
|
60
|
+
} catch {
|
|
61
|
+
return 'unreadable';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sameFiles(a, b) {
|
|
66
|
+
if (!a || !b || a.size !== b.size) return false;
|
|
67
|
+
for (const [k, v] of a) if (b.get(k) !== v) return false;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} o
|
|
73
|
+
* @param {() => Map} o.snapshot the files a version is made from, now
|
|
74
|
+
* @param {(o: { files: Map, previous: object }) => Promise<object>} o.make
|
|
75
|
+
* makes a version from the files as they are; `files` is the snapshot
|
|
76
|
+
* taken just before, `previous` the version served now
|
|
77
|
+
* @param {(v: object) => Promise<void>} o.show makes `v` the served version; throws to fail it
|
|
78
|
+
* @param {(v: object) => void} o.retire the previous version, once it is no longer served
|
|
79
|
+
* @param {object} o.first the version served at startup, with its `files` snapshot
|
|
80
|
+
* @param {{ dir: string, ignore?: (rel: string) => boolean }[]} [o.watch]
|
|
81
|
+
* folders whose change events start a version; `rel` is the changed
|
|
82
|
+
* path within the folder, with `/` separators
|
|
83
|
+
*/
|
|
84
|
+
export function createLiveVersions({ snapshot, make, show, retire, first, auto = true, watch = [], quietMs = 50, intervalMs = 1000, log = () => {}, onStatus = () => {} }) {
|
|
85
|
+
let current = first;
|
|
86
|
+
// The files the last attempt was made from, whether it was shown or failed.
|
|
87
|
+
let attempted = first.files;
|
|
88
|
+
let last = null;
|
|
89
|
+
let busy = false;
|
|
90
|
+
let closed = false;
|
|
91
|
+
let wanted = false;
|
|
92
|
+
let quiet = null;
|
|
93
|
+
let status = { state: 'ready', auto, pending: false, error: null };
|
|
94
|
+
|
|
95
|
+
const set = (next) => {
|
|
96
|
+
const changed = Object.keys(next).some((k) => status[k] !== next[k]);
|
|
97
|
+
status = { ...status, ...next };
|
|
98
|
+
if (changed) onStatus(status);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function update() {
|
|
102
|
+
if (busy) {
|
|
103
|
+
wanted = true;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
busy = true;
|
|
107
|
+
try {
|
|
108
|
+
do {
|
|
109
|
+
wanted = false;
|
|
110
|
+
set({ state: 'updating', error: null });
|
|
111
|
+
const before = snapshot();
|
|
112
|
+
let version = null;
|
|
113
|
+
let error = null;
|
|
114
|
+
try {
|
|
115
|
+
version = await make({ files: before, previous: current });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
error = err;
|
|
118
|
+
}
|
|
119
|
+
if (closed) {
|
|
120
|
+
version?.remove();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Files that changed while the copy was taken may have been copied
|
|
124
|
+
// half-written: the attempt does not count, and is made again once
|
|
125
|
+
// they settle.
|
|
126
|
+
if (!sameFiles(before, snapshot())) {
|
|
127
|
+
version?.remove();
|
|
128
|
+
await new Promise((r) => setTimeout(r, quietMs));
|
|
129
|
+
wanted = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
attempted = before;
|
|
133
|
+
if (version) {
|
|
134
|
+
version.files = before;
|
|
135
|
+
try {
|
|
136
|
+
await show(version);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
error = err;
|
|
139
|
+
version.remove();
|
|
140
|
+
version = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (closed) return;
|
|
144
|
+
if (version) {
|
|
145
|
+
const previous = current;
|
|
146
|
+
current = version;
|
|
147
|
+
retire(previous);
|
|
148
|
+
set({ state: 'ready', error: null, pending: false });
|
|
149
|
+
log('→ reloaded: serving the files as they are now');
|
|
150
|
+
} else {
|
|
151
|
+
const message = String(error?.message ?? error);
|
|
152
|
+
set({ state: 'failed', error: message, pending: false });
|
|
153
|
+
log(`✗ the game does not build — the page shows this until the files change:\n${message}`);
|
|
154
|
+
}
|
|
155
|
+
} while (wanted && !closed);
|
|
156
|
+
} finally {
|
|
157
|
+
busy = false;
|
|
158
|
+
}
|
|
159
|
+
// Changes that arrived while this update ran.
|
|
160
|
+
check({ settled: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Compares the files with the last attempt; `settled` says they have
|
|
164
|
+
// stopped changing.
|
|
165
|
+
function check({ settled }) {
|
|
166
|
+
if (closed || busy) return;
|
|
167
|
+
const now = snapshot();
|
|
168
|
+
const stable = settled || sameFiles(now, last);
|
|
169
|
+
last = now;
|
|
170
|
+
const pending = !sameFiles(now, attempted);
|
|
171
|
+
if (pending !== status.pending) set({ pending });
|
|
172
|
+
if (status.auto && pending && stable) update();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const watchers = [];
|
|
176
|
+
for (const { dir, ignore = () => false } of watch) {
|
|
177
|
+
try {
|
|
178
|
+
const w = watchFolder(dir, { recursive: true }, (_, name) => {
|
|
179
|
+
if (name && ignore(String(name).replace(/\\/g, '/'))) return;
|
|
180
|
+
clearTimeout(quiet);
|
|
181
|
+
quiet = setTimeout(() => check({ settled: true }), quietMs);
|
|
182
|
+
});
|
|
183
|
+
w.on('error', () => w.close());
|
|
184
|
+
watchers.push(w);
|
|
185
|
+
} catch { /* this folder is left to the poll */ }
|
|
186
|
+
}
|
|
187
|
+
const timer = setInterval(() => check({ settled: false }), intervalMs);
|
|
188
|
+
timer.unref();
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
get status() {
|
|
192
|
+
return status;
|
|
193
|
+
},
|
|
194
|
+
get current() {
|
|
195
|
+
return current;
|
|
196
|
+
},
|
|
197
|
+
// Makes a version of the files as they are now, whatever the mode.
|
|
198
|
+
reload() {
|
|
199
|
+
return update();
|
|
200
|
+
},
|
|
201
|
+
setAuto(on) {
|
|
202
|
+
set({ auto: !!on });
|
|
203
|
+
if (on) check({ settled: true });
|
|
204
|
+
},
|
|
205
|
+
close() {
|
|
206
|
+
closed = true;
|
|
207
|
+
clearInterval(timer);
|
|
208
|
+
clearTimeout(quiet);
|
|
209
|
+
for (const w of watchers) w.close();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -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
|
+
}
|