dotframe 0.1.2 → 0.1.4

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/main.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
  import { parseArgs } from "node:util";
3
3
  import pkg from "../package.json" with { type: "json" };
4
- import { build, deploy, device, relay } from "./commands/ship";
4
+ import { build, deploy, device, relay, vendor } from "./commands/ship";
5
5
  import { doctor } from "./commands/doctor";
6
6
  import { configCmd } from "./commands/config";
7
7
  import { desync, record, sim, verify } from "./commands/play";
8
8
  import { snap } from "./commands/snap";
9
9
  import { skills } from "./commands/skills";
10
10
  import { create, dev } from "./commands/new";
11
- import { CliError, type Ctx, fail } from "./lib";
11
+ import { CliError, type Ctx, fail, num } from "./lib";
12
12
 
13
13
  const HELP = `dotframe ${pkg.version}: build, test, and export dotframe games
14
14
 
@@ -25,7 +25,8 @@ Build and ship
25
25
  deploy <target> [--prod] gated: --yes, preview with --dry-run
26
26
  relay deploy [--region eze] gated
27
27
  device install <target> gated
28
- doctor [--fix] toolchain, links, config, signing
28
+ vendor <macos|windows> SDL3 + wgpu-native for native builds (~/.dotframe/vendor)
29
+ doctor [--fix] toolchain, vendor, links, config, sim render
29
30
  config get [key] | set <key> <json>
30
31
 
31
32
  Project
@@ -35,6 +36,84 @@ Project
35
36
 
36
37
  Global: --json --yes --dry-run --help --version`;
37
38
 
39
+ const INPUTS = `Inputs: --mash <seed> (random players) or --inputs f.jsonl (one [p0, p1] per frame, or {"frame": n, "inputs": [...]} held until the next line). --seed <n> seeds the sim, --options '<json>' overrides match options.`;
40
+
41
+ const COMMAND_HELP: Record<string, string> = {
42
+ sim: `dotframe sim [--mash <seed> | --inputs f.jsonl] [--frames 600] [--seed 1] [--options json] [--every n] [--through-over] [--json]
43
+
44
+ Runs the game headless and prints the final state and checksum. It stops when the match is over unless
45
+ --through-over (rematch and results flows). --every n adds a checksum trace.
46
+ ${INPUTS}
47
+
48
+ dotframe sim --mash 7 --frames 600 --json
49
+ dotframe sim --inputs combo.jsonl --options '{"stocks": 1}'`,
50
+ snap: `dotframe snap --frame <n> [--out snap-<n>.png] [--mash <seed> | --inputs f.jsonl] [--seed 1] [--options json] [--json]
51
+
52
+ Steps the sim to frame n in a real browser (WebGPU, through agent-browser) and screenshots it. Open the PNG and look.
53
+ ${INPUTS}
54
+
55
+ dotframe snap --frame 300 --mash 7 --out /tmp/f300.png`,
56
+ replay: `dotframe replay record <file> [--mash <seed> | --inputs f.jsonl] [--frames 1800] [--seed 1] [--options json] [--through-over]
57
+ dotframe replay verify <file...>
58
+
59
+ record stores seed, options, inputs, and a checksum every 60 frames. verify replays them and exits 1 with the
60
+ first divergent frame.
61
+
62
+ dotframe replay record replays/smoke.json --mash 7
63
+ dotframe replay verify replays/*.json`,
64
+ desync: `dotframe desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3] [--every 30] [--mash <seed> | --inputs f]
65
+
66
+ Runs a reference sim and two rollback peers over a simulated link. Peer 1 renders --renders times per step.
67
+ Compares checksums every frame and the full inspect() state every --every frames; exits 1 on any difference.
68
+ Warns when rollbacks exceed the sim's rollbackWindow.
69
+
70
+ dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
71
+ build: `dotframe build <target> [--release] [--dry-run] [--json]
72
+
73
+ Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
74
+ "entry": "main.native.ts"}}) stages the engine and game in .dotframe/native/<target> and writes dist/<target>/<name>.
75
+ Failures keep the full output in .dotframe/logs and return its path. --release refuses assets.localOnly.
76
+
77
+ dotframe build web
78
+ dotframe build ios --release`,
79
+ deploy: `dotframe deploy <target> [--prod] [--dry-run] [--yes] [--json]
80
+
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.
83
+
84
+ dotframe deploy web --prod --dry-run`,
85
+ relay: `dotframe relay deploy [--region eze] [--dry-run] [--yes]
86
+
87
+ Redeploys the netplay relay (dokploy) or deploys it to a region (fly). Gated like deploy.`,
88
+ device: `dotframe device install <target> [--dry-run] [--yes]
89
+
90
+ Installs targets.<target>.app on targets.<target>.device with devicectl. Gated like deploy.`,
91
+ vendor: `dotframe vendor <macos|windows> [--dry-run]
92
+
93
+ Downloads wgpu-native and builds SDL3 for native targets into DOTFRAME_VENDOR (default ~/.dotframe/vendor), which
94
+ survives reinstalling dotframe and is shared by every game.`,
95
+ doctor: `dotframe doctor [--fix] [--json]
96
+
97
+ Checks tools, dotframe.json, the sim (loads, renders with a stub Draw2D), placeholders, and vendor links.
98
+ --fix creates missing vendor symlinks.`,
99
+ config: `dotframe config get [key]
100
+ dotframe config set <key> <json> [--dry-run]
101
+
102
+ Dotted keys. Values are JSON (strings need inner quotes). set rewrites dotframe.json with 2-space indent and short
103
+ objects and arrays on one line.
104
+
105
+ dotframe config set targets.web.deploy.scope '"my-team"'`,
106
+ new: `dotframe new <name> [--template fighter|platformer|blank] [--no-install] [--json]
107
+
108
+ Scaffolds a game. Inside an existing git repo it does not run git init.`,
109
+ dev: `dotframe dev [--port 5173]
110
+
111
+ Builds the web target, serves it (index.html with no-cache, as in production), and rebuilds on change.`,
112
+ skills: `dotframe skills list | get <name...> [--full] | get --all | path [name]
113
+
114
+ Guides bundled with this CLI version. Start with: dotframe skills get core`,
115
+ };
116
+
38
117
  const { values, positionals } = parseArgs({
39
118
  allowPositionals: true,
40
119
  strict: false,
@@ -65,15 +144,17 @@ const { values, positionals } = parseArgs({
65
144
  template: { type: "string" },
66
145
  port: { type: "string" },
67
146
  "no-install": { type: "boolean" },
147
+ "through-over": { type: "boolean" },
68
148
  },
69
149
  });
70
150
 
71
151
  const ctx: Ctx = { json: values.json === true, yes: values.yes === true, dryRun: values["dry-run"] === true };
72
- const v = values as Record<string, string | undefined>;
152
+ const v = { ...(values as Record<string, string | undefined>), throughOver: values["through-over"] === true } as Record<string, string | undefined> & { throughOver: boolean };
73
153
  const [command, ...rest] = positionals;
74
154
 
75
155
  try {
76
156
  if (values.version) console.log(pkg.version);
157
+ else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
77
158
  else if (!command || values.help) console.log(HELP);
78
159
  else if (command === "sim") await sim(ctx, v);
79
160
  else if (command === "snap") await snap(ctx, v);
@@ -84,11 +165,12 @@ try {
84
165
  else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
85
166
  else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
86
167
  else if (command === "device" && rest[0] === "install") await device(ctx, rest[1] ?? "ios");
168
+ else if (command === "vendor") await vendor(ctx, rest[0]);
87
169
  else if (command === "doctor") await doctor(ctx, values.fix === true);
88
170
  else if (command === "config") await configCmd(ctx, rest);
89
171
  else if (command === "skills") await skills(ctx, rest, values.full === true, values.all === true);
90
172
  else if (command === "new") await create(ctx, rest[0], v.template ?? "blank", values["no-install"] !== true);
91
- else if (command === "dev") await dev(ctx, Number(v.port ?? "5173"));
173
+ else if (command === "dev") await dev(ctx, num("port", v.port, 5173, 1));
92
174
  else throw new CliError("UNKNOWN_COMMAND", `unknown command: ${positionals.join(" ")}`, "dotframe --help");
93
175
  } catch (error) {
94
176
  fail(ctx, error);
package/cli/native.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./lib";
5
+
6
+ // The engine this CLI belongs to: a git checkout or node_modules/dotframe.
7
+ export const ENGINE = resolve(import.meta.dir, "..");
8
+
9
+ export type NativePlatform = "macos" | "windows";
10
+
11
+ export interface NativeTarget {
12
+ platform: NativePlatform;
13
+ // Entry module, relative to the game root.
14
+ entry: string;
15
+ // Binary name; defaults to the game name.
16
+ name?: string;
17
+ }
18
+
19
+ // Vendored SDL3 and wgpu-native: DOTFRAME_VENDOR, else a checkout's own vendor/ when it is populated, else a
20
+ // per-user cache that survives reinstalling the package.
21
+ export function vendorDir(platform: NativePlatform): string {
22
+ if (process.env.DOTFRAME_VENDOR) return resolve(home(process.env.DOTFRAME_VENDOR));
23
+ const local = join(ENGINE, "vendor");
24
+ if (existsSync(join(local, "wgpu", platform))) return local;
25
+ return join(homedir(), ".dotframe", "vendor");
26
+ }
27
+
28
+ export interface VendorStatus {
29
+ dir: string;
30
+ missing: string[];
31
+ sdl: string | null;
32
+ }
33
+
34
+ export function vendorStatus(platform: NativePlatform): VendorStatus {
35
+ const dir = vendorDir(platform);
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`];
38
+ const missing = need.filter((p: string): boolean => !existsSync(join(dir, p)));
39
+ if (!sdl) missing.push("SDL3-<version> (headers)");
40
+ return { dir, missing, sdl };
41
+ }
42
+
43
+ export function vendorFix(platform: NativePlatform): string {
44
+ return `dotframe vendor ${platform}`;
45
+ }
46
+
47
+ const SKIP = new Set(["node_modules", ".git", ".dotframe", "dist", "build", ".vercel"]);
48
+
49
+ function copyTs(from: string, to: string): number {
50
+ let n = 0;
51
+ for (const name of readdirSync(from)) {
52
+ if (SKIP.has(name)) continue;
53
+ const src = join(from, name);
54
+ if (statSync(src).isDirectory()) n += copyTs(src, join(to, name));
55
+ else if (name.endsWith(".ts") || name.endsWith(".json")) {
56
+ mkdirSync(to, { recursive: true });
57
+ cpSync(src, join(to, name));
58
+ n += 1;
59
+ }
60
+ }
61
+ return n;
62
+ }
63
+
64
+ // scriptc's static build takes relative imports only, and treats anything under node_modules as package code
65
+ // for its dynamic engine. Staging copies the engine and the game side by side and rewrites "dotframe/..."
66
+ // imports to relative paths.
67
+ function rewriteImports(dir: string, engineRoot: string): void {
68
+ for (const name of readdirSync(dir)) {
69
+ const path = join(dir, name);
70
+ if (statSync(path).isDirectory()) {
71
+ rewriteImports(path, engineRoot);
72
+ continue;
73
+ }
74
+ if (!name.endsWith(".ts")) continue;
75
+ const text = readFileSync(path, "utf8");
76
+ const next = text.replace(/(from\s+|import\s*\(\s*)(["'])dotframe\/([^"']+)\2/g, (_m: string, head: string, q: string, rest: string): string => {
77
+ let rel = relative(dirname(path), join(engineRoot, rest));
78
+ if (!rel.startsWith(".")) rel = `./${rel}`;
79
+ return `${head}${q}${rel}${q}`;
80
+ });
81
+ if (next !== text) writeFileSync(path, next);
82
+ }
83
+ }
84
+
85
+ export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget): Promise<{ binary: string; log: string; steps: RunResult[] }> {
86
+ const platform = t.platform;
87
+ const skill = platform;
88
+ const tools = platform === "macos" ? ["clang", "scriptc"] : ["zig", "scriptc"];
89
+ for (const tool of tools) if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, tool === "scriptc" ? "npm i -g scriptc" : tool === "zig" ? "brew install zig" : "xcode-select --install", skill);
90
+ const vendor = vendorStatus(platform);
91
+ if (vendor.missing.length > 0) throw new CliError("VENDOR_MISSING", `native ${platform} needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix(platform), skill);
92
+
93
+ const stage = join(root, ".dotframe", "native", targetName);
94
+ const logDir = join(root, ".dotframe", "logs");
95
+ const log = join(logDir, `build-${targetName}.log`);
96
+ const out = join(root, "dist", targetName);
97
+ const binary = join(out, `${t.name ?? gameName}${platform === "windows" ? ".exe" : ""}`);
98
+ if (ctx.dryRun) {
99
+ print(ctx, { dryRun: true, platform, stage, vendor: vendor.dir, binary }, (): string => `would stage the engine and game in ${stage} and build ${binary}`);
100
+ return { binary, log, steps: [] };
101
+ }
102
+ rmSync(stage, { recursive: true, force: true });
103
+ mkdirSync(logDir, { recursive: true });
104
+ mkdirSync(out, { recursive: true });
105
+ const engineStage = join(stage, "dotframe");
106
+ cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
107
+ const gameStage = join(stage, "game");
108
+ copyTs(root, gameStage);
109
+ rewriteImports(gameStage, engineStage);
110
+
111
+ const inc = [`-I${join(vendor.dir, vendor.sdl ?? "", "include")}`, `-I${join(vendor.dir, "wgpu", platform, "include")}`];
112
+ const lib = join(stage, "lib");
113
+ mkdirSync(lib, { recursive: true });
114
+ const cc = platform === "macos" ? ["clang", "-O2", "-mmacosx-version-min=14.0"] : ["zig", "cc", "-target", "x86_64-windows-gnu", "-O2"];
115
+ const ar = platform === "macos" ? ["ar", "rcs"] : ["zig", "ar", "rcs"];
116
+ const steps = [
117
+ ...["df_native", "df_audio"].map((unit) => ({ label: `cc ${unit}`, argv: [...cc, "-c", join(ENGINE, "native", `${unit}.c`), ...inc, "-o", join(lib, `${unit}.o`)] })),
118
+ { label: "ar libdf_native", argv: [...ar, join(lib, "libdf_native.a"), join(lib, "df_native.o"), join(lib, "df_audio.o")] },
119
+ ];
120
+ const ffi = JSON.parse(readFileSync(join(ENGINE, "native", `ffi.${platform}.json`), "utf8")) as { libraries: string[] };
121
+ ffi.libraries = [join(lib, "libdf_native.a"), join(vendor.dir, "build", `sdl-${platform}`, "libSDL3.a"), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
122
+ writeFileSync(join(stage, "ffi.json"), JSON.stringify(ffi, null, 2));
123
+ const entry = join(gameStage, t.entry);
124
+ if (!existsSync(entry)) throw new CliError("ENTRY_MISSING", `native entry ${t.entry} does not exist`, `add ${t.entry} (see the macos skill) or fix targets.${targetName}.native.entry`, skill);
125
+ let env: Record<string, string> = {};
126
+ if (platform === "windows") {
127
+ // A devDependency of dotframe, so an npm install of dotframe does not bring it; the game can.
128
+ const pack = [ENGINE, root].map((dir: string): string => join(dir, "node_modules", "@scriptc", "runtime-win32-x64-msvc")).find((p: string): boolean => existsSync(p));
129
+ if (!pack) throw new CliError("TOOL_MISSING", "the scriptc Windows runtime pack is not installed", "bun add -d @scriptc/runtime-win32-x64-msvc", skill);
130
+ env = { SCRIPTC_TARGET: "x86_64-windows-gnu", SCRIPTC_RUNTIME_PACK: pack };
131
+ }
132
+ const scriptc = {
133
+ label: "scriptc",
134
+ argv: ["scriptc", "build", t.entry, "--ffi", join(stage, "ffi.json"), ...(platform === "windows" ? ["--windows-subsystem", "gui"] : []), "-o", binary],
135
+ cwd: gameStage,
136
+ env,
137
+ };
138
+ const results: RunResult[] = [];
139
+ const full: string[] = [];
140
+ for (const step of [...steps, scriptc]) {
141
+ if (!ctx.json) console.error(`> ${step.label}`);
142
+ const r = await exec(ctx, root, step);
143
+ full.push(`> ${step.label}: ${step.argv.join(" ")}\n${r.output}`);
144
+ writeFileSync(log, full.join("\n"));
145
+ results.push(r);
146
+ if (r.code !== 0) {
147
+ const count = r.output.match(/(\d+) errors?/)?.[1];
148
+ 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);
149
+ }
150
+ }
151
+ return { binary, log, steps: results };
152
+ }
package/cli/simkit.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { Sim, SimRun } from "../src/sim";
4
4
  import type { RenderGpu, Texture } from "../src/gpu";
5
- import { CliError, type Config } from "./lib";
5
+ import { CliError, type Config, num } from "./lib";
6
6
 
7
7
  let nextTexture = 1;
8
8
  // Texture ids restart per run so two runs of the same game have identical state graphs.
@@ -105,7 +105,7 @@ export function mash(sim: Sim, seed: number): InputSource {
105
105
 
106
106
  export function inputSource(sim: Sim, root: string, inputs: string | undefined, mashSeed: string | undefined): InputSource {
107
107
  if (inputs) return inputsFromFile(sim, resolve(process.cwd(), inputs));
108
- if (mashSeed !== undefined) return mash(sim, Number(mashSeed));
108
+ if (mashSeed !== undefined) return mash(sim, num("mash", mashSeed, 0));
109
109
  return { describe: "neutral", at: (): number[] => new Array(sim.players).fill(sim.neutral) };
110
110
  }
111
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -25,7 +25,8 @@
25
25
  "README.md",
26
26
  "CHANGELOG.md",
27
27
  "LICENSE",
28
- "skills"
28
+ "skills",
29
+ "vendor/toolchain"
29
30
  ],
30
31
  "repository": {
31
32
  "type": "git",
package/scripts/vendor.sh CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/bin/sh
2
2
  # Downloads wgpu-native prebuilts and builds SDL3 static for a target. Usage: scripts/vendor.sh <macos|windows>
3
+ # DOTFRAME_VENDOR picks the directory (the dotframe CLI uses ~/.dotframe/vendor); default is this checkout's vendor/.
3
4
  set -e
4
5
  target=$1
5
6
  root=$(cd "$(dirname "$0")/.." && pwd)
6
7
  wgpu_version=v29.0.1.1
7
8
  sdl_version=3.4.16
8
- vendor="$root/vendor"
9
+ vendor=${DOTFRAME_VENDOR:-$root/vendor}
10
+ toolchain="$root/vendor/toolchain"
9
11
  mkdir -p "$vendor/build"
10
12
 
11
13
  case $target in
@@ -28,7 +30,7 @@ fi
28
30
  if [ ! -f "$vendor/build/sdl-$target/libSDL3.a" ]; then
29
31
  case $target in
30
32
  macos) extra="-DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0" ;;
31
- windows) extra="-DCMAKE_TOOLCHAIN_FILE=$vendor/toolchain/zig-windows.cmake" ;;
33
+ windows) extra="-DCMAKE_TOOLCHAIN_FILE=$toolchain/zig-windows.cmake" ;;
32
34
  esac
33
35
  cmake -S "$vendor/SDL3-$sdl_version" -B "$vendor/build/sdl-$target" -DCMAKE_BUILD_TYPE=Release \
34
36
  -DSDL_SHARED=OFF -DSDL_STATIC=ON -DSDL_TEST_LIBRARY=OFF $extra
@@ -9,3 +9,4 @@ description: Asset licensing and loading for dotframe games. Use when adding spr
9
9
  - Fonts render through SDF atlases baked by `tools/bake-font.c`. Keep the font license (for example SIL OFL) with the atlas.
10
10
  - Headless runs (`sim`, `replay`, `desync`) skip asset loading. If gameplay depends on asset data (sprite boxes, frame counts), load that data in headless too, or the sim will differ from the real game.
11
11
  - `snap` loads assets from the game root over HTTP, so asset paths must be relative to the repo root.
12
+ - The engine loads PNG, not SVG. Rasterize SVGs at build time (for example with sharp in a build step) at the largest size the game draws them, and commit or generate the PNGs.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: core
3
- description: Core dotframe usage. Read before running any dotframe command: the edit, sim, snap, replay loop, the Sim contract, the trust ladder, and how to read errors.
3
+ description: Core dotframe usage. Read before running any dotframe command. Covers the edit, sim, snap, replay loop, the Sim contract, the trust ladder, and how to read errors.
4
4
  ---
5
5
  # dotframe core
6
6
 
@@ -17,6 +17,12 @@ dotframe is a TypeScript game engine. One codebase runs on the web (WebGPU), in
17
17
 
18
18
  Never claim a visual change works from the JSON alone. Snap it and look.
19
19
 
20
+ `sim` and `replay` stop when `over()` turns true. Pass `--through-over` to keep stepping into results and rematch flows.
21
+
22
+ ## agent-browser
23
+
24
+ `snap` drives agent-browser in its own session (`dotframe-snap`), writes to an absolute path, and checks the PNG size. When you drive agent-browser yourself, always pass `--session <name>` and absolute output paths: one daemon serves the whole machine, so a relative `screenshot out.png` lands in whatever directory the daemon started in (often another repo), and viewports can leak between sessions.
25
+
20
26
  ## Inputs
21
27
 
22
28
  - `--mash <seed>`: random mashing players, new input every 6 frames. Good default for smoke tests.
@@ -31,6 +37,10 @@ Never claim a visual change works from the JSON alone. Snap it and look.
31
37
  - All simulation state changes only inside `step`. `render` reads, never writes (no spawning particles or texts from draw code).
32
38
  - Randomness comes from a seeded generator that `save`/`restore` capture.
33
39
  - `inspect` returns the whole state graph so `desync` can name the exact field that differs.
40
+ - `render` must draw with whatever Draw2D it receives, including a stub that renders nothing. `desync` calls `render(stubDraw)` to prove rendering leaves state alone; a render that skips itself when there is no real renderer makes that check pass without checking anything. `dotframe doctor` fails `sim:render` when render makes no draw calls.
41
+ - Declare `rollbackWindow` (the most frames your netplay rolls back) so `desync` warns when a link needs more.
42
+
43
+ Prove a check can fail before trusting its green: make render change one field on purpose, run `desync`, and confirm it fails on peer 1 and names that field. Then revert.
34
44
 
35
45
  ## Trust ladder
36
46
 
@@ -8,7 +8,7 @@
8
8
  | INPUTS_MISSING / BAD_INPUTS | Inputs file missing or a line has the wrong shape | One JSON array or `{frame, inputs}` per line, one input per player |
9
9
  | BAD_OPTIONS | `--options` is not a JSON object | Quote it: `--options '{"stocks": 1}'` |
10
10
  | UNKNOWN_TARGET | Target not in dotframe.json | Use a listed target or add one |
11
- | STEP_FAILED | A build step exited non-zero | Read the step output; `dotframe doctor` |
11
+ | STEP_FAILED | A build step exited non-zero | Read the file in `log` (full output); `dotframe doctor` |
12
12
  | ASSETS_LOCAL_ONLY | A release would ship unlicensed assets | See `assets` |
13
13
  | APPROVAL_REQUIRED (exit 2) | External action without `--yes` | Show the `--dry-run` plan to the human |
14
14
  | DEPLOY_SOURCE_DIR | Deploy dir looks like source (repo root, .git, dotframe.json) | Point `out` at the build folder |
@@ -17,6 +17,10 @@
17
17
  | DEPLOY_FAILED / INSTALL_FAILED | Provider command failed | Message carries the provider output |
18
18
  | TOOL_MISSING | A required CLI is not installed | `dotframe doctor` lists the install command |
19
19
  | SNAP_TIMEOUT / SNAP_PAGE_ERROR / BROWSER_FAILED | The snap page did not report ready | Usually no WebGPU in the browser; see message |
20
+ | BAD_ARG / MISSING_ARG | A flag value is not a valid number or duration, or a required flag is missing | Use the value in `fix` |
21
+ | VENDOR_MISSING | Native build without SDL3 or wgpu-native | `dotframe vendor <platform>` |
22
+ | ENTRY_MISSING | The native entry module does not exist | Add `main.native.ts` or fix `native.entry` |
23
+ | SNAP_SIZE | The screenshot size does not match the game window | Close other agent-browser sessions and retry |
20
24
  | UNSUPPORTED | The command does not apply (e.g. desync on a 1-player sim) | |
21
25
  | REPLAY_MISSING | Replay file not found | `dotframe replay record` |
22
26
  | UNKNOWN_SKILL / UNKNOWN_TEMPLATE / UNKNOWN_COMMAND | Typo | Read the `fix` list |
@@ -15,6 +15,7 @@ dotframe deploy web --prod --yes # only after the human approved the plan
15
15
 
16
16
  - Deploy only the built folder (`targets.web.out`). The CLI refuses a folder that looks like source (repo root, `.git`, `dotframe.json`): that mistake once put the wrong game in production.
17
17
  - Do not connect the repo to Vercel's Git integration for these games. A push deploys the repo root.
18
+ - Templates ship `scripts/build-web.ts`, which does both of the next points. Keep it when you change the build.
18
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.
19
20
  - `deploy` without `--prod` makes a preview. Prefer it for checks.
20
21
  - Verify a deploy by opening the URL with agent-browser and taking a screenshot, not by the exit code.
@@ -1,16 +1,45 @@
1
1
  ---
2
2
  name: macos
3
- description: Build and run native macOS (and Windows) binaries of a dotframe game with scriptc. Use when compiling natively, benchmarking, or debugging the native backend.
3
+ description: Build and run native macOS (and Windows) binaries of a dotframe game with scriptc. Use when compiling natively, setting up the vendored SDL3 and wgpu-native, reading a native build failure, benchmarking, or debugging the native backend.
4
4
  ---
5
5
  # macos
6
6
 
7
7
  ```sh
8
- dotframe build macos --json
8
+ dotframe vendor macos # once per machine: wgpu-native + SDL3 into ~/.dotframe/vendor (about 1 minute)
9
+ dotframe doctor # vendor:<target>, scriptc, zig for windows
10
+ dotframe build macos --json # dist/macos/<name>
11
+ ./dist/macos/<name> # run from the game root: asset paths are relative to it
9
12
  ```
10
13
 
11
- The step runs dotframe's `scripts/build-native.sh macos <entry> <name>`: it compiles the C shim (SDL3 + wgpu-native) and the game with scriptc into one binary with no JavaScript engine. Windows cross-builds with zig (`build-native.sh windows`).
14
+ ## Native targets
15
+
16
+ A target with `"native"` is built by the CLI itself:
17
+
18
+ ```json
19
+ "macos": { "native": { "platform": "macos", "entry": "main.native.ts" } },
20
+ "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
21
+ ```
22
+
23
+ The CLI copies the engine (`src/`) and the game's `.ts` and `.json` files into `.dotframe/native/<target>/`, rewrites `dotframe/...` imports to relative paths, compiles the C shim against the vendored SDL3 and wgpu-native, and runs scriptc. This is why it works from `node_modules`: scriptc treats code under `node_modules` as package code for its dynamic engine and takes only relative imports in a static build, so building in place fails.
24
+
25
+ - Vendor lives in `DOTFRAME_VENDOR`, else a dotframe git checkout's `vendor/` when populated, else `~/.dotframe/vendor`. It survives reinstalling dotframe and is shared by every game.
26
+ - Output goes to the game's `dist/<target>/`, never into `node_modules`.
27
+ - On failure, `error.log` (and the message) points at `.dotframe/logs/build-<target>.log` with the full compiler output. Read it; the message only carries the tail.
28
+ - Windows cross-builds with zig (`brew install zig`), needs `dotframe vendor windows`, and needs scriptc's Windows runtime pack in the game (`bun add -d @scriptc/runtime-win32-x64-msvc`, about 63 MB, so templates leave it out).
29
+ - A target with `"steps"` instead (like Crafter Smash, which vendors dotframe as a submodule) runs its own scripts.
30
+
31
+ ## Code that compiles natively
32
+
33
+ scriptc compiles a subset of TypeScript. What tripped the templates:
34
+
35
+ - No `Object.assign` on an existing object: assign fields one by one.
36
+ - Object spread needs every field to have an earlier source: `{ ...c, taken: false }` over a mapped literal fails; build the records explicitly.
37
+ - Structural types become record copies, so engine backends are plain objects of functions, not classes behind interfaces.
38
+ - Keep native-only code in `main.native.ts`; share the rest (see the templates' `src/setup.ts`).
39
+
40
+ Run `dotframe build macos` early and after each feature: one scriptc error is a quick fix, ninety are a port.
41
+
42
+ ## Other notes
12
43
 
13
- - Games may need env vars at run time (Crafter Smash: `CHARS=railly,anthony SMASH_ROOT=<repo> DOTFRAME=<repo>/vendor/dotframe`).
14
44
  - Benchmarks mean nothing while other processes load the machine. Check `top` first and say what else was running.
15
45
  - Hidden or occluded windows skip frames and can spin a core. Keep test windows visible.
16
- - scriptc compiles a subset of TypeScript: structural types become record copies, so engine backends are plain objects of functions, not classes behind interfaces.
@@ -16,7 +16,9 @@ It runs a reference simulation and two peers over a deterministic simulated link
16
16
 
17
17
  - `firstDivergentFrame`: the checksum differed. A rollback or determinism bug.
18
18
  - `firstStateDifference`: `{frame, path}` of the first field that differs at a checkpoint (`--every 30`). Catches state the checksum does not cover. Needs `inspect()` in the sim.
19
- - `rollbacks`, `maxRollbackFrames`: how hard the link worked. Keep max under the game's rollback window.
19
+ - `rollbacks`, `maxRollbackFrames`: how hard the link worked. With `rollbackWindow` in the sim, `warnings` says when a link needs deeper rollbacks than the game allows (real play would stall).
20
+
21
+ Long runs: rendering on the stub costs whatever the game's render costs. `--renders 0` speeds desync up but turns off the render purity half of the check; use it for rollback depth or latency sweeps, and keep at least one run with renders. `dotframe doctor` also checks render purity on its own, in about a second.
20
22
 
21
23
  Run several seeds and a high latency (474 ms is what a transatlantic relay measured). A pass on one seed proves little.
22
24
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dotframe
3
- description: Agent-first CLI for the dotframe game engine. Use when building, testing, or exporting a dotframe game: creating a game from a template, simulating or replaying frames headless, rendering a frame to check what a player sees, debugging rollback netplay desyncs, or building and deploying for web, Discord Activities, iOS, and native macOS or Windows. Triggers include "make a game", "dotframe", "simulate the match", "snap a frame", "replay", "desync", "netplay", "build for iOS", "deploy the game", or any repo with a dotframe.json.
3
+ description: Agent-first CLI for the dotframe game engine. Use when building, testing, or exporting a dotframe game, such as creating a game from a template, simulating or replaying frames headless, rendering a frame to check what a player sees, debugging rollback netplay desyncs, or building and deploying for web, Discord Activities, iOS, and native macOS or Windows. Triggers include "make a game", "dotframe", "simulate the match", "snap a frame", "replay", "desync", "netplay", "build for iOS", "deploy the game", or any repo with a dotframe.json.
4
4
  allowed-tools: Bash(dotframe:*), Bash(npx dotframe:*), Bash(bunx dotframe:*)
5
5
  hidden: true
6
6
  ---
package/src/sim.ts CHANGED
@@ -33,7 +33,9 @@ export interface SimRun {
33
33
  // Optional: the whole simulation state as a plain object graph. desync walks it at checkpoints and reports the
34
34
  // first differing path, which catches state the checksum does not cover.
35
35
  inspect?: () => unknown;
36
- // Optional: draws the current frame; must not change simulation state.
36
+ // Optional: draws the current frame; must not change simulation state. It must also draw with a stub Draw2D
37
+ // that renders nothing: desync calls it that way to prove rendering leaves the simulation alone, so a render
38
+ // that skips itself without a real draw makes that check blind.
37
39
  render?: (draw: Draw2D) => void;
38
40
  }
39
41
 
@@ -49,6 +51,8 @@ export interface Sim {
49
51
  // A random input, for mashing scripts. next() returns [0, 1).
50
52
  random: (next: () => number) => number;
51
53
  create: (platform: SimPlatform) => SimRun;
54
+ // Optional: the most frames the game's netplay can roll back. desync warns when a link needs more.
55
+ rollbackWindow?: number;
52
56
  }
53
57
 
54
58
  export function defineSim(sim: Sim): Sim {
package/src/web/run.ts CHANGED
@@ -12,6 +12,8 @@ import type { Audio } from "../audio";
12
12
  import { type Input, keyCodes, type Pointer, type Touch } from "../input";
13
13
  import type { Storage } from "../storage";
14
14
 
15
+ // Engine buffers are always plain ArrayBuffer-backed; the casts below satisfy TS 5.9+ WebGPU typings, which reject
16
+ // Uint8Array<ArrayBufferLike> (it could be a SharedArrayBuffer).
15
17
  const vertexFormats: GPUVertexFormat[] = ["float32x2", "float32x3", "float32x4", "float32", "unorm8x4"];
16
18
 
17
19
  export async function loadBytes(path: string): Promise<Uint8Array> {
@@ -78,13 +80,13 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
78
80
  if (usage & BufferUsage.Index) flags |= GPUBufferUsage.INDEX;
79
81
  if (usage & BufferUsage.Uniform) flags |= GPUBufferUsage.UNIFORM;
80
82
  const buffer = device.createBuffer({ size: Math.ceil(data.byteLength / 4) * 4, usage: flags });
81
- device.queue.writeBuffer(buffer, 0, data);
83
+ device.queue.writeBuffer(buffer, 0, data as Uint8Array<ArrayBuffer>);
82
84
  buffers.push(buffer);
83
85
  return buffers.length - 1;
84
86
  },
85
87
  writeBuffer: (buffer: number, data: Uint8Array): void => {
86
88
  const target = buffers[buffer];
87
- if (target) device.queue.writeBuffer(target, 0, data);
89
+ if (target) device.queue.writeBuffer(target, 0, data as Uint8Array<ArrayBuffer>);
88
90
  },
89
91
  destroyBuffer: (buffer: number): void => {
90
92
  buffers[buffer]?.destroy();
@@ -144,7 +146,7 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
144
146
  },
145
147
  createTexture: (width: number, height: number, rgba: Uint8Array, smooth: boolean): Texture =>
146
148
  uploadTexture(width, height, smooth, (texture) =>
147
- device.queue.writeTexture({ texture }, rgba, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]),
149
+ device.queue.writeTexture({ texture }, rgba as Uint8Array<ArrayBuffer>, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]),
148
150
  ),
149
151
  createImage: async (png: Uint8Array, smooth: boolean): Promise<Texture> => {
150
152
  const bitmap = await createImageBitmap(new Blob([new Uint8Array(png)], { type: "image/png" }), { premultiplyAlpha: "none" });
@@ -189,12 +191,13 @@ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
189
191
  };
190
192
 
191
193
  const pressed = new Set<string>();
192
- globalThis.addEventListener("keydown", (event) => {
194
+ // window, not globalThis: with Bun's types loaded, globalThis listeners receive a plain Event.
195
+ window.addEventListener("keydown", (event: KeyboardEvent) => {
193
196
  if (keyCodes.includes(event.code)) event.preventDefault();
194
197
  pressed.add(event.code);
195
198
  });
196
- globalThis.addEventListener("keyup", (event) => pressed.delete(event.code));
197
- globalThis.addEventListener("blur", () => pressed.clear());
199
+ window.addEventListener("keyup", (event: KeyboardEvent) => pressed.delete(event.code));
200
+ window.addEventListener("blur", () => pressed.clear());
198
201
  const pointer: Pointer = { x: 0, y: 0, buttons: 0 };
199
202
  const trackPointer = (event: PointerEvent): void => {
200
203
  const rect = canvas.getBoundingClientRect();
@@ -3,13 +3,12 @@
3
3
  "sim": "sim.ts",
4
4
  "targets": {
5
5
  "web": {
6
- "steps": [
7
- { "label": "bundle", "argv": ["bun", "build", "main.web.ts", "--outfile", "dist/web/main.js", "--target", "browser", "--minify"] },
8
- { "label": "page", "argv": ["cp", "index.html", "dist/web/index.html"] }
9
- ],
6
+ "steps": [{ "label": "bundle (hashed)", "argv": ["bun", "scripts/build-web.ts"] }],
10
7
  "out": "dist/web",
11
8
  "deploy": { "provider": "vercel", "project": "__NAME__", "scope": "__SCOPE__" }
12
- }
9
+ },
10
+ "macos": { "native": { "platform": "macos", "entry": "main.native.ts" } },
11
+ "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
13
12
  },
14
13
  "assets": { "localOnly": [] }
15
14
  }
@@ -0,0 +1,6 @@
1
+ // Native entry for dotframe build macos|windows. The CLI stages it with the engine and compiles it with scriptc.
2
+ import { run } from "dotframe/src/native/run";
3
+ import { WINDOW } from "./src/game";
4
+ import { createSetup } from "./src/setup";
5
+
6
+ await run(WINDOW, createSetup());
@@ -1,27 +1,5 @@
1
- import { createDraw2D } from "dotframe/src/draw2d";
2
- import type { Frame } from "dotframe/src/gpu";
3
- import { Key } from "dotframe/src/input";
4
- import type { Platform } from "dotframe/src/platform";
5
1
  import { run } from "dotframe/src/web/run";
6
- import { createGame, keyInput, PLAYERS, render, step, WINDOW } from "./src/game";
2
+ import { WINDOW } from "./src/game";
3
+ import { createSetup } from "./src/setup";
7
4
 
8
- const STEP = 1 / 60;
9
-
10
- await run(WINDOW, ({ gpu, input }: Platform): Frame => {
11
- const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
12
- const game = createGame(Math.floor(Math.random() * 1e9));
13
- let simulated = -1;
14
- return (time: number): boolean => {
15
- if (simulated < 0) simulated = time;
16
- // Fixed 60 Hz steps, so the browser plays exactly what dotframe sim simulates.
17
- for (let n = 0; simulated + STEP <= time && n < 5; n++) {
18
- const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
19
- step(game, keys.slice(0, PLAYERS));
20
- simulated += STEP;
21
- }
22
- draw.begin();
23
- render(game, draw);
24
- draw.end({ r: 0, g: 0, b: 0 });
25
- return true;
26
- };
27
- });
5
+ await run(WINDOW, createSetup());