dotframe 0.1.5 → 0.1.7

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/cli/ios.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { CliError, type Ctx, exec, print, type RunResult, which } from "./lib";
5
+ import { ENGINE, sdlLibrary, stageGame, vendorFix, vendorStatus } from "./native";
6
+
7
+ export interface IosTarget {
8
+ platform: "ios";
9
+ // Entry module exporting init(base: string): void and frame(time: number): boolean (see the ios skill).
10
+ entry: string;
11
+ bundleId: string;
12
+ team: string;
13
+ // PNG, any size; scaled to the 1024 px App Store icon.
14
+ icon?: string;
15
+ orientation?: "landscape" | "portrait";
16
+ // Folder bundled under <app>/game/<assets>; the engine fonts go under <app>/game/dotframe/assets/fonts.
17
+ assets?: string;
18
+ // Home screen name; defaults to the game name.
19
+ displayName?: string;
20
+ }
21
+
22
+ const SKILL = "ios";
23
+
24
+ async function scriptcVersion(ctx: Ctx, root: string): Promise<string> {
25
+ const r = await exec({ ...ctx, json: true }, root, { label: "scriptc version", argv: ["scriptc", "--version"] });
26
+ return r.output.trim();
27
+ }
28
+
29
+ // scriptc's iOS runtime pack must match the compiler. Looked up in the game, the engine, then ~/.dotframe/scriptc-ios.
30
+ export async function iosRuntimePack(ctx: Ctx, root: string): Promise<{ path: string | null; want: string; found: string | null }> {
31
+ const want = await scriptcVersion(ctx, root);
32
+ const dirs = [root, ENGINE, join(homedir(), ".dotframe", "scriptc-ios")];
33
+ for (const dir of dirs) {
34
+ const pack = join(dir, "node_modules", "@scriptc", "runtime-ios-arm64");
35
+ if (!existsSync(join(pack, "package.json"))) continue;
36
+ const found = (JSON.parse(await Bun.file(join(pack, "package.json")).text()) as { version: string }).version;
37
+ return { path: found === want ? pack : null, want, found };
38
+ }
39
+ return { path: null, want, found: null };
40
+ }
41
+
42
+ export function iosPackFix(want: string): string {
43
+ // Fresh scriptc releases trip bun's minimum release age for a couple of days.
44
+ return `mkdir -p ~/.dotframe/scriptc-ios && cd ~/.dotframe/scriptc-ios && bun add @scriptc/runtime-ios-arm64@${want} --minimum-release-age 0`;
45
+ }
46
+
47
+ function identifier(name: string): string {
48
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "") || "game";
49
+ }
50
+
51
+ function scheme(name: string): string {
52
+ return name.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("") || "Game";
53
+ }
54
+
55
+ function hostC(id: string, display: string): string {
56
+ return `// iOS host for ${display}, generated by dotframe build ios. SDL3 owns the app lifecycle through main callbacks
57
+ // and drives the scriptc library: init once with the bundle's game folder, then frame per display refresh.
58
+ #define SDL_MAIN_USE_CALLBACKS 1
59
+ #include <SDL3/SDL.h>
60
+ #include <SDL3/SDL_main.h>
61
+ #include <stdint.h>
62
+ #include <string.h>
63
+
64
+ void ${id}_runtime_init(void);
65
+ int32_t ${id}_register_callbacks(void);
66
+ void ${id}_init(const uint8_t *base, int64_t base_len);
67
+ uint8_t ${id}_frame(double time);
68
+ void df_handle_event(const SDL_Event *event);
69
+ void df_close(void);
70
+
71
+ static Uint64 g_start;
72
+
73
+ SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) {
74
+ (void)appstate;
75
+ (void)argc;
76
+ (void)argv;
77
+ ${id}_runtime_init();
78
+ int32_t failed = ${id}_register_callbacks();
79
+ if (failed != 0) {
80
+ SDL_Log("${id}: %d callbacks failed to register", failed);
81
+ return SDL_APP_FAILURE;
82
+ }
83
+ const char *bundle = SDL_GetBasePath();
84
+ if (!bundle) return SDL_APP_FAILURE;
85
+ char base[4096];
86
+ SDL_snprintf(base, sizeof base, "%sgame", bundle);
87
+ ${id}_init((const uint8_t *)base, (int64_t)strlen(base));
88
+ g_start = SDL_GetTicksNS();
89
+ return SDL_APP_CONTINUE;
90
+ }
91
+
92
+ SDL_AppResult SDL_AppIterate(void *appstate) {
93
+ (void)appstate;
94
+ double time = (double)(SDL_GetTicksNS() - g_start) / 1e9;
95
+ return ${id}_frame(time) ? SDL_APP_CONTINUE : SDL_APP_SUCCESS;
96
+ }
97
+
98
+ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
99
+ (void)appstate;
100
+ df_handle_event(event);
101
+ return event->type == SDL_EVENT_QUIT ? SDL_APP_SUCCESS : SDL_APP_CONTINUE;
102
+ }
103
+
104
+ void SDL_AppQuit(void *appstate, SDL_AppResult result) {
105
+ (void)appstate;
106
+ (void)result;
107
+ df_close();
108
+ }
109
+
110
+ // scriptc library mode references the promise runtime from bytes IO but ships none; library mode has no async.
111
+ void scr_promise_settled_ref(void) { SDL_assert_always(!"scriptc promise in library mode"); }
112
+ void scr_promise_settled_void(void) { SDL_assert_always(!"scriptc promise in library mode"); }
113
+ `;
114
+ }
115
+
116
+ function infoPlist(display: string, orientation: "landscape" | "portrait"): string {
117
+ const orientations =
118
+ orientation === "portrait"
119
+ ? ["UIInterfaceOrientationPortrait"]
120
+ : ["UIInterfaceOrientationLandscapeLeft", "UIInterfaceOrientationLandscapeRight"];
121
+ return `<?xml version="1.0" encoding="UTF-8"?>
122
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
123
+ <plist version="1.0">
124
+ <dict>
125
+ <key>CFBundleDisplayName</key><string>${display}</string>
126
+ <key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
127
+ <key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
128
+ <key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
129
+ <key>CFBundlePackageType</key><string>APPL</string>
130
+ <key>CFBundleShortVersionString</key><string>0.1</string>
131
+ <key>CFBundleVersion</key><string>1</string>
132
+ <key>LSRequiresIPhoneOS</key><true/>
133
+ <key>UIRequiresFullScreen</key><true/>
134
+ <key>UIStatusBarHidden</key><true/>
135
+ <key>UIViewControllerBasedStatusBarAppearance</key><false/>
136
+ <key>UILaunchScreen</key><dict/>
137
+ <key>UISupportedInterfaceOrientations</key>
138
+ <array>
139
+ ${orientations.map((o) => ` <string>${o}</string>`).join("\n")}
140
+ </array>
141
+ </dict>
142
+ </plist>
143
+ `;
144
+ }
145
+
146
+ function projectYml(o: { name: string; id: string; bundleId: string; team: string; vendor: string; sdl: string; lib: string; icon: boolean }): string {
147
+ const frameworks = ["UIKit", "Foundation", "Metal", "QuartzCore", "AVFoundation", "AudioToolbox", "CoreAudio", "CoreBluetooth", "CoreGraphics", "CoreHaptics", "CoreMotion", "GameController", "IOSurface", "UniformTypeIdentifiers", "CoreMedia", "CoreVideo", "OpenGLES"];
148
+ return `name: ${o.name}
149
+ options:
150
+ deploymentTarget:
151
+ iOS: "17.0"
152
+ targets:
153
+ ${o.name}:
154
+ type: application
155
+ platform: iOS
156
+ sources:
157
+ - path: host/main.c
158
+ - path: ${o.id}_glue.c
159
+ - path: ${join(ENGINE, "native", "df_native.c")}
160
+ - path: ${join(ENGINE, "native", "df_audio.c")}
161
+ - path: game
162
+ type: folder
163
+ buildPhase: resources
164
+ ${o.icon ? " - path: Assets.xcassets\n" : ""} settings:
165
+ base:
166
+ PRODUCT_BUNDLE_IDENTIFIER: ${o.bundleId}
167
+ DEVELOPMENT_TEAM: ${o.team}
168
+ CODE_SIGN_STYLE: Automatic
169
+ INFOPLIST_FILE: host/Info.plist
170
+ ${o.icon ? " ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon\n" : ""} TARGETED_DEVICE_FAMILY: "1,2"
171
+ HEADER_SEARCH_PATHS:
172
+ - ${join(o.vendor, o.sdl, "include")}
173
+ - ${join(o.vendor, "wgpu", "ios", "include")}
174
+ - ${join(ENGINE, "native")}
175
+ OTHER_LDFLAGS:
176
+ - ${o.lib}
177
+ - ${join(o.vendor, sdlLibrary("ios"))}
178
+ - ${join(o.vendor, "wgpu", "ios", "lib", "libwgpu_native.a")}
179
+ - -lc++
180
+ - -liconv
181
+ GCC_WARN_INHIBIT_ALL_WARNINGS: YES
182
+ ENABLE_BITCODE: NO
183
+ dependencies:
184
+ ${frameworks.map((f) => ` - sdk: ${f}.framework`).join("\n")}
185
+ `;
186
+ }
187
+
188
+ export async function buildIos(ctx: Ctx, root: string, gameName: string, targetName: string, t: IosTarget): Promise<{ app: string; log: string; steps: RunResult[] }> {
189
+ for (const tool of ["scriptc", "xcodegen", "xcodebuild", "sips"]) {
190
+ if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, tool === "xcodegen" ? "brew install xcodegen" : tool === "scriptc" ? "npm i -g scriptc" : "install Xcode", SKILL);
191
+ }
192
+ for (const key of ["entry", "bundleId", "team"] as const) {
193
+ if (!t[key]) throw new CliError("BAD_CONFIG", `targets.${targetName}.native.${key} is required for iOS`, `dotframe config set targets.${targetName}.native.${key} '"..."'`, SKILL);
194
+ }
195
+ if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `iOS entry ${t.entry} does not exist`, "add it (see the ios skill: export init and frame)", SKILL);
196
+ const vendor = vendorStatus("ios");
197
+ if (vendor.missing.length > 0) throw new CliError("VENDOR_MISSING", `iOS needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix("ios"), SKILL);
198
+ const pack = await iosRuntimePack(ctx, root);
199
+ if (!pack.path) {
200
+ throw new CliError("TOOL_MISSING", pack.found ? `scriptc iOS runtime pack is ${pack.found}, scriptc is ${pack.want}` : `scriptc iOS runtime pack ${pack.want} not found`, iosPackFix(pack.want), SKILL);
201
+ }
202
+
203
+ const display = t.displayName ?? gameName;
204
+ const id = identifier(gameName);
205
+ const name = scheme(display);
206
+ const stage = join(root, ".dotframe", "native", targetName);
207
+ const out = join(root, "dist", targetName);
208
+ const app = join(out, `${name}.app`);
209
+ const logDir = join(root, ".dotframe", "logs");
210
+ const log = join(logDir, `build-${targetName}.log`);
211
+ if (ctx.dryRun) {
212
+ print(ctx, { dryRun: true, platform: "ios", stage, app, scheme: name, bundleId: t.bundleId, team: t.team, runtimePack: pack.path }, (): string => `would stage ${t.entry} in ${stage} and build ${app}`);
213
+ return { app, log, steps: [] };
214
+ }
215
+ rmSync(stage, { recursive: true, force: true });
216
+ mkdirSync(join(stage, "host"), { recursive: true });
217
+ mkdirSync(logDir, { recursive: true });
218
+ const engineStage = join(stage, "dotframe");
219
+ cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
220
+ const gameStage = stageGame(root, t.entry, join(stage, "tree"), engineStage);
221
+ writeFileSync(
222
+ join(stage, "app.json"),
223
+ JSON.stringify({ name: id, entry: relative(stage, join(gameStage, t.entry)), exports: [{ export: "init", params: ["string"], returns: "void" }, { export: "frame", params: ["f64"], returns: "bool" }] }, null, 2),
224
+ );
225
+ writeFileSync(join(stage, "host", "main.c"), hostC(id, display));
226
+ writeFileSync(join(stage, "host", "Info.plist"), infoPlist(display, t.orientation ?? "landscape"));
227
+ // Bundle resources: the game's assets and the engine fonts, under <app>/game.
228
+ const bundle = join(stage, "game");
229
+ mkdirSync(bundle, { recursive: true });
230
+ const assets = t.assets ?? "assets";
231
+ if (existsSync(join(root, assets))) cpSync(join(root, assets), join(bundle, assets), { recursive: true });
232
+ cpSync(join(ENGINE, "assets", "fonts"), join(bundle, "dotframe", "assets", "fonts"), { recursive: true });
233
+
234
+ const steps: { label: string; argv: string[]; cwd?: string; env?: Record<string, string> }[] = [];
235
+ if (t.icon) {
236
+ const icons = join(stage, "Assets.xcassets", "AppIcon.appiconset");
237
+ mkdirSync(icons, { recursive: true });
238
+ writeFileSync(join(stage, "Assets.xcassets", "Contents.json"), '{"info":{"author":"xcode","version":1}}\n');
239
+ writeFileSync(join(icons, "Contents.json"), '{"images":[{"filename":"icon-1024.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}}\n');
240
+ steps.push({ label: "icon", argv: ["sips", "-z", "1024", "1024", resolve(root, t.icon), "--out", join(icons, "icon-1024.png")] });
241
+ }
242
+ steps.push(
243
+ { label: "library glue", argv: ["bun", join(ENGINE, "tools", "gen-library-glue.ts"), join(stage, "app.json"), stage] },
244
+ { label: "scriptc library", argv: ["scriptc", "build", "--lib", "--profile", `${id}.profile.json`], cwd: stage, env: { SCRIPTC_TARGET: "aarch64-apple-ios", SCRIPTC_RUNTIME_PACK: pack.path } },
245
+ );
246
+ const results: RunResult[] = [];
247
+ const full: string[] = [];
248
+ const run = async (step: { label: string; argv: string[]; cwd?: string; env?: Record<string, string> }): Promise<void> => {
249
+ if (!ctx.json) console.error(`> ${step.label}`);
250
+ const r = await exec(ctx, root, step);
251
+ full.push(`> ${step.label}: ${step.argv.join(" ")}\n${r.output}`);
252
+ writeFileSync(log, full.join("\n"));
253
+ results.push(r);
254
+ if (r.code !== 0) {
255
+ const count = r.output.match(/(\d+) errors?/)?.[1];
256
+ throw new CliError("STEP_FAILED", `step "${step.label}" exited ${r.code}${count ? ` with ${count} errors` : ""}; full output in ${log}\n${r.tail}`, `read ${log}`, SKILL, 1, log);
257
+ }
258
+ };
259
+ for (const step of steps) await run(step);
260
+ const lib = readdirSync(join(stage, ".scriptc")).find((f: string): boolean => f.endsWith(".lib.a"));
261
+ if (!lib) throw new CliError("STEP_FAILED", "scriptc produced no .lib.a", `read ${log}`, SKILL, 1, log);
262
+ writeFileSync(join(stage, "project.yml"), projectYml({ name, id, bundleId: t.bundleId, team: t.team, vendor: vendor.dir, sdl: vendor.sdl ?? "", lib: join(stage, ".scriptc", lib), icon: !!t.icon }));
263
+ await run({ label: "xcodegen", argv: ["xcodegen", "generate"], cwd: stage });
264
+ await run({ label: "xcodebuild", argv: ["xcodebuild", "-scheme", name, "-destination", "generic/platform=iOS", "-allowProvisioningUpdates", "-derivedDataPath", join(stage, "derived"), "build", "-quiet"], cwd: stage });
265
+ const built = join(stage, "derived", "Build", "Products", "Debug-iphoneos", `${name}.app`);
266
+ if (!existsSync(built)) throw new CliError("STEP_FAILED", `xcodebuild finished but ${built} is missing`, `read ${log}`, SKILL, 1, log);
267
+ rmSync(app, { recursive: true, force: true });
268
+ mkdirSync(out, { recursive: true });
269
+ cpSync(built, app, { recursive: true });
270
+ return { app, log, steps: results };
271
+ }
package/cli/lib.ts CHANGED
@@ -53,11 +53,11 @@ export interface Command {
53
53
  export interface Target {
54
54
  steps?: Command[];
55
55
  // Native targets built by the CLI itself (staged engine, vendored SDL3 and wgpu-native).
56
- native?: import("./native").NativeTarget;
56
+ native?: import("./native").NativeTarget | import("./ios").IosTarget;
57
57
  out?: string;
58
58
  app?: string;
59
59
  device?: string;
60
- deploy?: { provider: "vercel"; project: string; scope: string };
60
+ deploy?: { provider: "vercel"; project: string; scope: string } | { provider: "dokploy"; compose: string; dir?: string };
61
61
  }
62
62
 
63
63
  export interface Config {
@@ -68,6 +68,8 @@ export interface Config {
68
68
  relay?: { provider: "dokploy"; compose: string } | { provider: "fly"; app: string; config: string; region?: string };
69
69
  links?: { path: string; target: string }[];
70
70
  assets?: { localOnly?: string[] };
71
+ // Tools the build needs besides bun (for example ffmpeg); doctor checks them and docker images install them.
72
+ requires?: string[];
71
73
  }
72
74
 
73
75
  export const CONFIG_FILE = "dotframe.json";
package/cli/main.ts CHANGED
@@ -3,12 +3,15 @@ import { parseArgs } from "node:util";
3
3
  import pkg from "../package.json" with { type: "json" };
4
4
  import { build, deploy, device, relay, vendor } from "./commands/ship";
5
5
  import { doctor } from "./commands/doctor";
6
+ import { deployInit } from "./commands/docker";
6
7
  import { configCmd } from "./commands/config";
7
8
  import { desync, record, sim, verify } from "./commands/play";
8
9
  import { snap } from "./commands/snap";
10
+ import { playOnline } from "./commands/online";
9
11
  import { skills } from "./commands/skills";
10
12
  import { create, dev } from "./commands/new";
11
- import { CliError, type Ctx, fail, num } from "./lib";
13
+ import { CliError, type Ctx, fail, num, print } from "./lib";
14
+ import { startRelay } from "./relay";
12
15
 
13
16
  const HELP = `dotframe ${pkg.version}: build, test, and export dotframe games
14
17
 
@@ -18,15 +21,18 @@ Play (headless, deterministic)
18
21
  sim [--inputs f.jsonl | --mash <seed>] [--frames 600] [--seed 1] [--options json] [--every n]
19
22
  snap --frame <n> [--out frame.png] [--inputs f | --mash <seed>] [--seed 1] [--options json]
20
23
  replay record <file> | verify <file...>
24
+ play --online [room] [--frames 600] two browsers, local relay, scripted match
21
25
  desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3]
22
26
 
23
27
  Build and ship
24
28
  build <target> [--release] targets come from dotframe.json
25
29
  deploy <target> [--prod] gated: --yes, preview with --dry-run
30
+ deploy init <target> --provider dokploy [--compose id] Dockerfiles, nginx, compose
31
+ relay serve [--port 8787] local netplay relay (same protocol as production)
26
32
  relay deploy [--region eze] gated
27
33
  device install <target> gated
28
34
  vendor <macos|windows> SDL3 + wgpu-native for native builds (~/.dotframe/vendor)
29
- doctor [--fix] toolchain, vendor, links, config, sim render
35
+ doctor [--fix] [--docker] toolchain, vendor, links, config, sim render and math
30
36
  config get [key] | set <key> <json>
31
37
 
32
38
  Project
@@ -68,6 +74,11 @@ Compares checksums every frame and the full inspect() state every --every frames
68
74
  Warns when rollbacks exceed the sim's rollbackWindow.
69
75
 
70
76
  dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
77
+ play: `dotframe play --online [room] [--frames 600] [--out .dotframe/play] [--json]
78
+
79
+ Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash=1|2.
80
+ Waits until both confirm --frames frames, compares their checksums every 30 frames, and saves a screenshot of each.
81
+ The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe (templates do).`,
71
82
  build: `dotframe build <target> [--release] [--dry-run] [--json]
72
83
 
73
84
  Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
@@ -77,14 +88,23 @@ Failures keep the full output in .dotframe/logs and return its path. --release r
77
88
  dotframe build web
78
89
  dotframe build ios --release`,
79
90
  deploy: `dotframe deploy <target> [--prod] [--dry-run] [--yes] [--json]
91
+ dotframe deploy init <target> --provider dokploy [--compose <id>] [--yes]
92
+
93
+ provider vercel: uploads targets.<target>.out. provider dokploy: redeploys the compose stack (it builds from the
94
+ pushed branch), waits for the result, and on failure returns the build log. Without --yes it stops with
95
+ APPROVAL_REQUIRED (exit 2); show the --dry-run plan to a human first.
80
96
 
81
- Deploys targets.<target>.out with its deploy provider. Without --yes it stops with APPROVAL_REQUIRED (exit 2).
82
- Show the --dry-run plan to a human first.
97
+ init writes deploy/Dockerfile.web (installs "requires" tools, builds with dotframe), deploy/Dockerfile.relay
98
+ (dotframe relay serve), deploy/nginx.conf (no-cache index.html) and deploy/compose.yaml (web at /, relay at /relay).
83
99
 
84
- dotframe deploy web --prod --dry-run`,
85
- relay: `dotframe relay deploy [--region eze] [--dry-run] [--yes]
100
+ dotframe deploy web --prod --dry-run
101
+ dotframe deploy init web --provider dokploy`,
102
+ relay: `dotframe relay serve [--port 8787]
103
+ dotframe relay deploy [--region eze] [--dry-run] [--yes]
86
104
 
87
- Redeploys the netplay relay (dokploy) or deploys it to a region (fly). Gated like deploy.`,
105
+ serve runs the netplay relay locally (PORT env or --port): it pairs two clients per ?room= and forwards their
106
+ messages, the protocol connectRelay in dotframe/src/netplay speaks. deploy redeploys the production relay
107
+ (dokploy) or deploys it to a region (fly), gated like deploy.`,
88
108
  device: `dotframe device install <target> [--dry-run] [--yes]
89
109
 
90
110
  Installs targets.<target>.app on targets.<target>.device with devicectl. Gated like deploy.`,
@@ -92,10 +112,11 @@ Installs targets.<target>.app on targets.<target>.device with devicectl. Gated l
92
112
 
93
113
  Downloads wgpu-native and builds SDL3 for native targets into DOTFRAME_VENDOR (default ~/.dotframe/vendor), which
94
114
  survives reinstalling dotframe and is shared by every game.`,
95
- doctor: `dotframe doctor [--fix] [--json]
115
+ doctor: `dotframe doctor [--fix] [--docker] [--json]
96
116
 
97
- Checks tools, dotframe.json, the sim (loads, renders with a stub Draw2D), placeholders, and vendor links.
98
- --fix creates missing vendor symlinks.`,
117
+ Checks tools (including "requires" in dotframe.json), vendor, links, placeholders, the sim (render with a stub
118
+ Draw2D leaves state alone) and platform-dependent math in code the sim reaches. --fix creates missing vendor
119
+ symlinks; --docker builds the dokploy web image locally, as the server would.`,
99
120
  config: `dotframe config get [key]
100
121
  dotframe config set <key> <json> [--dry-run]
101
122
 
@@ -145,6 +166,10 @@ const { values, positionals } = parseArgs({
145
166
  port: { type: "string" },
146
167
  "no-install": { type: "boolean" },
147
168
  "through-over": { type: "boolean" },
169
+ docker: { type: "boolean" },
170
+ online: { type: "boolean" },
171
+ provider: { type: "string" },
172
+ compose: { type: "string" },
148
173
  },
149
174
  });
150
175
 
@@ -156,17 +181,25 @@ try {
156
181
  if (values.version) console.log(pkg.version);
157
182
  else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
158
183
  else if (!command || values.help) console.log(HELP);
184
+ else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out });
159
185
  else if (command === "sim") await sim(ctx, v);
160
186
  else if (command === "snap") await snap(ctx, v);
161
187
  else if (command === "replay" && rest[0] === "record") await record(ctx, rest[1], v);
162
188
  else if (command === "replay" && rest[0] === "verify") await verify(ctx, rest.slice(1));
163
189
  else if (command === "desync") await desync(ctx, v);
164
190
  else if (command === "build") await build(ctx, rest[0], values.release === true);
191
+ else if (command === "deploy" && rest[0] === "init") await deployInit(ctx, rest[1], v.provider, v.compose);
165
192
  else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
166
193
  else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
194
+ else if (command === "relay" && rest[0] === "serve") {
195
+ const port = num("port", v.port, Number(process.env.PORT ?? "8787"), 1);
196
+ const server = startRelay(port, (line: string): void => console.error(line));
197
+ print(ctx, { relay: `ws://localhost:${server.port}`, port: server.port }, (): string => `relay listening on ws://localhost:${server.port} (Ctrl+C to stop)`);
198
+ await new Promise((): void => {});
199
+ }
167
200
  else if (command === "device" && rest[0] === "install") await device(ctx, rest[1] ?? "ios");
168
201
  else if (command === "vendor") await vendor(ctx, rest[0]);
169
- else if (command === "doctor") await doctor(ctx, values.fix === true);
202
+ else if (command === "doctor") await doctor(ctx, values.fix === true, values.docker === true);
170
203
  else if (command === "config") await configCmd(ctx, rest);
171
204
  else if (command === "skills") await skills(ctx, rest, values.full === true, values.all === true);
172
205
  else if (command === "new") await create(ctx, rest[0], v.template ?? "blank", values["no-install"] !== true);
package/cli/native.ts CHANGED
@@ -6,10 +6,10 @@ import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./
6
6
  // The engine this CLI belongs to: a git checkout or node_modules/dotframe.
7
7
  export const ENGINE = resolve(import.meta.dir, "..");
8
8
 
9
- export type NativePlatform = "macos" | "windows";
9
+ export type NativePlatform = "macos" | "windows" | "ios";
10
10
 
11
11
  export interface NativeTarget {
12
- platform: NativePlatform;
12
+ platform: "macos" | "windows";
13
13
  // Entry module, relative to the game root.
14
14
  entry: string;
15
15
  // Binary name; defaults to the game name.
@@ -34,12 +34,17 @@ export interface VendorStatus {
34
34
  export function vendorStatus(platform: NativePlatform): VendorStatus {
35
35
  const dir = vendorDir(platform);
36
36
  const sdl = existsSync(dir) ? (readdirSync(dir).find((d: string): boolean => d.startsWith("SDL3-")) ?? null) : null;
37
- const need = [`wgpu/${platform}/lib/libwgpu_native.a`, `build/sdl-${platform}/libSDL3.a`];
37
+ const need = [`wgpu/${platform}/lib/libwgpu_native.a`, sdlLibrary(platform)];
38
38
  const missing = need.filter((p: string): boolean => !existsSync(join(dir, p)));
39
39
  if (!sdl) missing.push("SDL3-<version> (headers)");
40
40
  return { dir, missing, sdl };
41
41
  }
42
42
 
43
+ // Relative to the vendor dir; iOS builds with the Xcode generator.
44
+ export function sdlLibrary(platform: NativePlatform): string {
45
+ return platform === "ios" ? "build/sdl-ios/Release-iphoneos/libSDL3.a" : `build/sdl-${platform}/libSDL3.a`;
46
+ }
47
+
43
48
  export function vendorFix(platform: NativePlatform): string {
44
49
  return `dotframe vendor ${platform}`;
45
50
  }
@@ -83,7 +88,7 @@ interface Graph {
83
88
 
84
89
  // Every source file the entry reaches: relative imports (including ones that leave the game root) and workspace
85
90
  // packages. "dotframe/..." is the engine, staged separately.
86
- function importGraph(entry: string): Graph {
91
+ export function importGraph(entry: string): Graph {
87
92
  const files: string[] = [];
88
93
  const links = new Map<string, Map<string, string>>();
89
94
  const queue = [realpathSync(entry)];
@@ -123,7 +128,7 @@ function rel(fromFile: string, to: string): string {
123
128
  // scriptc's static build takes relative imports only, and treats anything under node_modules as package code for
124
129
  // its dynamic engine. Staging copies the engine and every reached game file side by side, keeping their relative
125
130
  // layout, and rewrites "dotframe/..." and workspace imports to relative paths.
126
- function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
131
+ export function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
127
132
  const graph = importGraph(join(root, entry));
128
133
  const base = commonDir([realpathSync(root) + sep + "x", ...graph.files]);
129
134
  const staged = (file: string): string => join(tree, relative(base, file));
package/cli/relay.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+
3
+ // Netplay relay: pairs two clients per room and forwards every message to the other one, untouched. Rooms come
4
+ // from the URL (?room=...); in Discord the room is the Activity instance id. Same protocol as connectRelay.
5
+ interface Peer {
6
+ room: string;
7
+ slot: number;
8
+ }
9
+
10
+ export function startRelay(port: number, log: (line: string) => void = (): void => {}): Server<Peer> {
11
+ const rooms = new Map<string, (ServerWebSocket<Peer> | null)[]>();
12
+ const send = (ws: ServerWebSocket<Peer> | null, message: object): void => {
13
+ if (ws) ws.send(JSON.stringify(message));
14
+ };
15
+ return Bun.serve<Peer>({
16
+ port,
17
+ fetch(request, server) {
18
+ const url = new URL(request.url);
19
+ if (url.pathname.endsWith("/health")) return new Response("ok");
20
+ const room = url.searchParams.get("room") ?? "";
21
+ if (room === "" || room.length > 128) return new Response("room required", { status: 400 });
22
+ const peers = rooms.get(room) ?? [null, null];
23
+ const slot = peers[0] === null ? 0 : peers[1] === null ? 1 : -1;
24
+ if (slot < 0) return new Response("room full", { status: 409 });
25
+ if (server.upgrade(request, { data: { room, slot } })) return undefined;
26
+ return new Response("websocket only", { status: 426 });
27
+ },
28
+ websocket: {
29
+ open(ws) {
30
+ const peers = rooms.get(ws.data.room) ?? [null, null];
31
+ // Two sockets can race for the same free slot between fetch and open.
32
+ if (peers[ws.data.slot] !== null) {
33
+ const other = peers[1 - ws.data.slot] === null ? 1 - ws.data.slot : -1;
34
+ if (other < 0) {
35
+ ws.close(4009, "room full");
36
+ return;
37
+ }
38
+ ws.data.slot = other;
39
+ }
40
+ peers[ws.data.slot] = ws;
41
+ rooms.set(ws.data.room, peers);
42
+ log(`join room=${ws.data.room} slot=${ws.data.slot}`);
43
+ send(ws, { t: "hello", slot: ws.data.slot });
44
+ const other = peers[1 - ws.data.slot];
45
+ if (other) {
46
+ send(ws, { t: "peer", here: true });
47
+ send(other, { t: "peer", here: true });
48
+ }
49
+ },
50
+ message(ws, message) {
51
+ const peers = rooms.get(ws.data.room);
52
+ const other = peers ? peers[1 - ws.data.slot] : null;
53
+ if (other) other.send(message);
54
+ },
55
+ close(ws) {
56
+ const peers = rooms.get(ws.data.room);
57
+ if (!peers || peers[ws.data.slot] !== ws) return;
58
+ peers[ws.data.slot] = null;
59
+ log(`leave room=${ws.data.room} slot=${ws.data.slot}`);
60
+ send(peers[1 - ws.data.slot], { t: "peer", here: false });
61
+ if (peers[0] === null && peers[1] === null) rooms.delete(ws.data.room);
62
+ },
63
+ },
64
+ });
65
+ }
@@ -117,6 +117,9 @@ int32_t df_open(int32_t width, int32_t height, const uint8_t *title, size_t titl
117
117
  memcpy(title_buf, title, n);
118
118
  title_buf[n] = 0;
119
119
 
120
+ // Touches stay touches: games read input.touches() for them, and a synthesized mouse would also put a phantom
121
+ // pointer under the finger (on iOS, at the release point), which breaks games that read both.
122
+ SDL_SetHint(SDL_HINT_TOUCH_MOUSE_EVENTS, "0");
120
123
  if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) return -1;
121
124
  SDL_WindowFlags flags = SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_RESIZABLE;
122
125
  #if defined(__APPLE__)
@@ -227,6 +230,8 @@ static void mouse_relative(double *nx, double *ny) {
227
230
  // Field 0 x, 1 y (normalized), 2 buttons (bit 0 left, bit 1 middle, bit 2 right).
228
231
  double df_mouse(int32_t field) {
229
232
  if (field == 2) {
233
+ // Clicks in other apps must not reach the game: buttons count only while the window has input focus.
234
+ if (!(SDL_GetWindowFlags(g_window) & SDL_WINDOW_INPUT_FOCUS)) return 0;
230
235
  SDL_MouseButtonFlags flags = SDL_GetGlobalMouseState(NULL, NULL);
231
236
  return (flags & SDL_BUTTON_LMASK ? 1 : 0) | (flags & SDL_BUTTON_MMASK ? 2 : 0) | (flags & SDL_BUTTON_RMASK ? 4 : 0);
232
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "TS-first game engine and agent-first CLI: one codebase for web, Discord, iOS and native binaries",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/scripts/vendor.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # Downloads wgpu-native prebuilts and builds SDL3 static for a target. Usage: scripts/vendor.sh <macos|windows>
2
+ # Downloads wgpu-native prebuilts and builds SDL3 static for a target. Usage: scripts/vendor.sh <macos|windows|ios>
3
3
  # DOTFRAME_VENDOR picks the directory (the dotframe CLI uses ~/.dotframe/vendor); default is this checkout's vendor/.
4
4
  set -e
5
5
  target=$1
@@ -13,7 +13,8 @@ mkdir -p "$vendor/build"
13
13
  case $target in
14
14
  macos) wgpu_asset=wgpu-macos-aarch64-release ;;
15
15
  windows) wgpu_asset=wgpu-windows-x86_64-gnu-release ;;
16
- *) echo "usage: $0 <macos|windows>" >&2; exit 2 ;;
16
+ ios) wgpu_asset=wgpu-ios-aarch64-release ;;
17
+ *) echo "usage: $0 <macos|windows|ios>" >&2; exit 2 ;;
17
18
  esac
18
19
 
19
20
  if [ ! -f "$vendor/wgpu/$target/lib/libwgpu_native.a" ]; then
@@ -27,12 +28,17 @@ if [ ! -d "$vendor/SDL3-$sdl_version" ]; then
27
28
  curl -fsSL "https://github.com/libsdl-org/SDL/releases/download/release-$sdl_version/SDL3-$sdl_version.tar.gz" | tar xz -C "$vendor"
28
29
  fi
29
30
 
30
- if [ ! -f "$vendor/build/sdl-$target/libSDL3.a" ]; then
31
+ # iOS builds through the Xcode generator, which puts the archive under Release-iphoneos/.
32
+ sdl_lib="$vendor/build/sdl-$target/libSDL3.a"
33
+ [ "$target" = ios ] && sdl_lib="$vendor/build/sdl-ios/Release-iphoneos/libSDL3.a"
34
+ if [ ! -f "$sdl_lib" ]; then
35
+ generator=""
31
36
  case $target in
32
37
  macos) extra="-DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0" ;;
33
38
  windows) extra="-DCMAKE_TOOLCHAIN_FILE=$toolchain/zig-windows.cmake" ;;
39
+ ios) extra="-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=17.0"; generator="-G Xcode" ;;
34
40
  esac
35
- cmake -S "$vendor/SDL3-$sdl_version" -B "$vendor/build/sdl-$target" -DCMAKE_BUILD_TYPE=Release \
41
+ cmake -S "$vendor/SDL3-$sdl_version" -B "$vendor/build/sdl-$target" $generator -DCMAKE_BUILD_TYPE=Release \
36
42
  -DSDL_SHARED=OFF -DSDL_STATIC=ON -DSDL_TEST_LIBRARY=OFF $extra
37
- cmake --build "$vendor/build/sdl-$target" -j 4
43
+ cmake --build "$vendor/build/sdl-$target" --config Release -j 4
38
44
  fi
@@ -56,6 +56,12 @@ Prove a check can fail before trusting its green: make render change one field o
56
56
 
57
57
  Pass `--json` and read `ok`. Errors look like `{"ok": false, "error": {"code", "message", "fix", "skill"}}`. Apply `fix`, and when it is not obvious run `dotframe skills get <skill>`. Exit codes: 0 ok, 1 failure, 2 approval required. Full list: `dotframe skills get core --full`.
58
58
 
59
+ ## Engine modules agents reach for
60
+
61
+ - `dotframe/src/detmath`: deterministic sin, cos, tan, atan, atan2, exp, log, pow, hypot for simulation code.
62
+ - `dotframe/src/checksum`: a fixed-order checksum over numbers.
63
+ - `dotframe/src/netplay` and `dotframe/src/relay-client`: rollback netplay and the relay connection (see netplay).
64
+
59
65
  ## Other guides
60
66
 
61
67
  `dotframe skills list`. Load the one that matches the task: netplay, export-web, discord, ios, macos, relay, game-design, assets.
@@ -19,3 +19,17 @@ dotframe deploy web --prod --yes # only after the human approved the plan
19
19
  - Content-hash the bundle (`main.<hash>.js`) and serve `index.html` with `Cache-Control: no-cache`, so browsers and Discord's proxy never run a stale build.
20
20
  - `deploy` without `--prod` makes a preview. Prefer it for checks.
21
21
  - Verify a deploy by opening the URL with agent-browser and taking a screenshot, not by the exit code.
22
+
23
+ ## Docker and Dokploy (VPS)
24
+
25
+ ```sh
26
+ dotframe deploy init web --provider dokploy # deploy/Dockerfile.web, Dockerfile.relay, nginx.conf, compose.yaml
27
+ dotframe doctor --docker # builds the web image locally, as the server will
28
+ dotframe config set targets.web.deploy.compose '"<compose id>"' # vps compose list
29
+ dotframe deploy web --dry-run && dotframe deploy web --yes
30
+ ```
31
+
32
+ - The web image installs the tools in `requires` (dotframe.json, for example `["ffmpeg"]`) and builds with dotframe; the relay image runs `dotframe relay serve`. The build context is the repo root, so monorepo ports see workspace code.
33
+ - In Dokploy, route the domain's `/` to service `web` (port 80) and `/relay` to service `relay` (port 8787). For Discord, map `/` and `/relay` to that host.
34
+ - Dokploy builds the pushed branch, not your working tree: deploy warns about unpushed or uncommitted work. On failure it returns the build log (through `vps compose logs`).
35
+ - Hash bundles with a tool that exists in the image: `shasum` is missing on Debian images (use `sha256sum` or Bun's hashing, as `scripts/build-web.ts` does).