@bitmagic/cli 0.1.64-dev.1 → 0.1.64-dev.11

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.
Files changed (151) hide show
  1. package/README.md +80 -5
  2. package/assets/blender-level/README.md +25 -0
  3. package/assets/blender-level/audit.py +232 -0
  4. package/assets/blender-level/bmlevel/__init__.py +294 -0
  5. package/assets/blender-level/bmlevel/core.py +438 -0
  6. package/assets/blender-level/bmlevel/scene_bpy.py +186 -0
  7. package/assets/blender-level/bmlevel/scene_null.py +44 -0
  8. package/assets/blender-level/example_level.py +80 -0
  9. package/assets/blender-level/find_blender.py +62 -0
  10. package/assets/blender-level/review.py +117 -0
  11. package/assets/blender-level/sync_world.py +85 -0
  12. package/assets/blender-level/tests/test_core.py +189 -0
  13. package/dist/assets/keep-glb.d.ts +20 -0
  14. package/dist/assets/keep-glb.js +45 -0
  15. package/dist/assets/keep-glb.js.map +1 -0
  16. package/dist/cli.d.ts +5 -0
  17. package/dist/cli.js +2 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/assets.d.ts +3 -0
  20. package/dist/commands/assets.js +33 -3
  21. package/dist/commands/assets.js.map +1 -1
  22. package/dist/commands/cover.js +7 -1
  23. package/dist/commands/cover.js.map +1 -1
  24. package/dist/commands/forge.d.ts +2 -0
  25. package/dist/commands/forge.js +76 -12
  26. package/dist/commands/forge.js.map +1 -1
  27. package/dist/commands/generate.d.ts +88 -1
  28. package/dist/commands/generate.js +400 -21
  29. package/dist/commands/generate.js.map +1 -1
  30. package/dist/commands/init.d.ts +11 -0
  31. package/dist/commands/init.js +33 -1
  32. package/dist/commands/init.js.map +1 -1
  33. package/dist/commands/judge.d.ts +0 -11
  34. package/dist/commands/judge.js +15 -2
  35. package/dist/commands/judge.js.map +1 -1
  36. package/dist/commands/levels.d.ts +1 -1
  37. package/dist/commands/levels.js +14 -3
  38. package/dist/commands/levels.js.map +1 -1
  39. package/dist/commands/reference.js +5 -1
  40. package/dist/commands/reference.js.map +1 -1
  41. package/dist/commands/reset-account.js +2 -11
  42. package/dist/commands/reset-account.js.map +1 -1
  43. package/dist/commands/tools.d.ts +1 -0
  44. package/dist/commands/tools.js +73 -0
  45. package/dist/commands/tools.js.map +1 -0
  46. package/dist/commands/trailer.js +18 -4
  47. package/dist/commands/trailer.js.map +1 -1
  48. package/dist/editor/asset-detail-panel.d.ts +8 -3
  49. package/dist/editor/asset-detail-panel.js +11 -1
  50. package/dist/editor/asset-detail-panel.js.map +1 -1
  51. package/dist/editor/hud-replay-page.js +317 -22
  52. package/dist/editor/hud-replay-page.js.map +1 -1
  53. package/dist/editor/shell-page.js +117 -7
  54. package/dist/editor/shell-page.js.map +1 -1
  55. package/dist/forge/apply-modifications.js +70 -2
  56. package/dist/forge/apply-modifications.js.map +1 -1
  57. package/dist/forge/progress.d.ts +10 -0
  58. package/dist/forge/progress.js +7 -6
  59. package/dist/forge/progress.js.map +1 -1
  60. package/dist/forge/run-pipeline.d.ts +8 -0
  61. package/dist/forge/run-pipeline.js +37 -2
  62. package/dist/forge/run-pipeline.js.map +1 -1
  63. package/dist/forge/stream.d.ts +7 -1
  64. package/dist/forge/stream.js +5 -2
  65. package/dist/forge/stream.js.map +1 -1
  66. package/dist/forge/two-d-gate.d.ts +82 -0
  67. package/dist/forge/two-d-gate.js +232 -0
  68. package/dist/forge/two-d-gate.js.map +1 -0
  69. package/dist/generate/animation-from-video.d.ts +11 -3
  70. package/dist/generate/animation-from-video.js +12 -2
  71. package/dist/generate/animation-from-video.js.map +1 -1
  72. package/dist/generate/model.d.ts +11 -0
  73. package/dist/generate/model.js +41 -1
  74. package/dist/generate/model.js.map +1 -1
  75. package/dist/generate/prop.d.ts +9 -0
  76. package/dist/generate/prop.js +59 -9
  77. package/dist/generate/prop.js.map +1 -1
  78. package/dist/generate/video-review.d.ts +28 -0
  79. package/dist/generate/video-review.js +120 -0
  80. package/dist/generate/video-review.js.map +1 -0
  81. package/dist/project/art-style.d.ts +34 -0
  82. package/dist/project/art-style.js +63 -0
  83. package/dist/project/art-style.js.map +1 -0
  84. package/dist/prompt.d.ts +9 -0
  85. package/dist/prompt.js +36 -0
  86. package/dist/prompt.js.map +1 -0
  87. package/dist/publish/bundle.d.ts +12 -0
  88. package/dist/publish/bundle.js +32 -0
  89. package/dist/publish/bundle.js.map +1 -1
  90. package/dist/render/qr-png.d.ts +6 -3
  91. package/dist/render/qr-png.js +8 -8
  92. package/dist/render/qr-png.js.map +1 -1
  93. package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
  94. package/dist/scaffold/blender-level-skill-files.js +570 -0
  95. package/dist/scaffold/blender-level-skill-files.js.map +1 -0
  96. package/dist/scaffold/claude-settings.js +2 -0
  97. package/dist/scaffold/claude-settings.js.map +1 -1
  98. package/dist/scaffold/project-files.d.ts +2 -1
  99. package/dist/scaffold/project-files.js +125 -42
  100. package/dist/scaffold/project-files.js.map +1 -1
  101. package/dist/scaffold/project.d.ts +9 -0
  102. package/dist/scaffold/project.js +29 -3
  103. package/dist/scaffold/project.js.map +1 -1
  104. package/dist/scaffold/reference-guidance.d.ts +1 -1
  105. package/dist/scaffold/reference-guidance.js +2 -1
  106. package/dist/scaffold/reference-guidance.js.map +1 -1
  107. package/dist/scaffold/skill-files.js +3 -1
  108. package/dist/scaffold/skill-files.js.map +1 -1
  109. package/dist/scaffold/unity-skill-files.js +1 -2
  110. package/dist/scaffold/unity-skill-files.js.map +1 -1
  111. package/dist/tools/bundles.d.ts +21 -0
  112. package/dist/tools/bundles.js +67 -0
  113. package/dist/tools/bundles.js.map +1 -0
  114. package/dist/trailer/ffmpeg.d.ts +10 -0
  115. package/dist/trailer/ffmpeg.js +51 -6
  116. package/dist/trailer/ffmpeg.js.map +1 -1
  117. package/dist/trailer/hud-raster.d.ts +12 -12
  118. package/dist/trailer/hud-raster.js +85 -32
  119. package/dist/trailer/hud-raster.js.map +1 -1
  120. package/dist/trailer/hud-state.d.ts +75 -4
  121. package/dist/trailer/hud-state.js +132 -17
  122. package/dist/trailer/hud-state.js.map +1 -1
  123. package/dist/trailer/io.d.ts +2 -0
  124. package/dist/trailer/io.js +6 -1
  125. package/dist/trailer/io.js.map +1 -1
  126. package/dist/trailer/link.d.ts +1 -0
  127. package/dist/trailer/link.js +15 -0
  128. package/dist/trailer/link.js.map +1 -0
  129. package/dist/trailer/make.d.ts +2 -0
  130. package/dist/trailer/make.js +1 -0
  131. package/dist/trailer/make.js.map +1 -1
  132. package/dist/trailer/merge.js +32 -11
  133. package/dist/trailer/merge.js.map +1 -1
  134. package/dist/trailer/music-beats.js +4 -39
  135. package/dist/trailer/music-beats.js.map +1 -1
  136. package/dist/trailer/render.d.ts +2 -0
  137. package/dist/trailer/render.js +94 -48
  138. package/dist/trailer/render.js.map +1 -1
  139. package/dist/trailer/timeline-types.d.ts +19 -0
  140. package/dist/trailer/timeline-types.js +42 -2
  141. package/dist/trailer/timeline-types.js.map +1 -1
  142. package/dist/trailer/title-raster.js +2 -1
  143. package/dist/trailer/title-raster.js.map +1 -1
  144. package/dist/trailer/titles.js +6 -1
  145. package/dist/trailer/titles.js.map +1 -1
  146. package/dist/trailer/video-chain.d.ts +96 -8
  147. package/dist/trailer/video-chain.js +183 -0
  148. package/dist/trailer/video-chain.js.map +1 -1
  149. package/dist/verify/browser.js +4 -1
  150. package/dist/verify/browser.js.map +1 -1
  151. package/package.json +4 -4
package/README.md CHANGED
@@ -192,7 +192,7 @@ browser reloads itself on every change and the assets panel shows each generatio
192
192
  | `bitmagic logout [--env <env>]` | Remove stored credentials for an environment. |
193
193
  | `bitmagic reset-account [--env <env>] [--yes] [--keep-cli]` | **Deletes your account** on `dev` or `local` so the first-run experience can be tested again — games, sparks, profile, terms acceptance and Bitmagic Pro (cancelled in Stripe first). Then clears that environment's stored credentials **and uninstalls this CLI**, so the next run starts where a new creator starts: `npm install -g @bitmagic/cli`. `--keep-cli` skips that last step. Refuses `prod`, where the endpoint is not deployed at all. Asks you to type the environment name; `--yes` skips that and is required in a shell with no terminal. Your project directory is left alone — its `gameId` no longer exists, so start the fresh run in a new one. |
194
194
  | `bitmagic whoami [--env <env>]` | Show the identity the CLI is authenticated as — the account's email address alongside its user id — and which environment answered, including where that environment was chosen from. |
195
- | `bitmagic init [dir] [--template <id>] [--name <name>] [--idea "<pitch>"] [--env <env>] [--force]` | Scaffold a new project: mints a game, downloads the vendored engine, writes `AGENTS.md`, `GAME-DESIGN.md` and templates. Starts from `empty-3d` unless told otherwise. `--idea` seeds the design doc's pitch **and** picks the template whose camera and controls fit it — "a doom-like corridor shooter" scaffolds `first-person`, "jump between floating islands" scaffolds `sidescroller`. `--template` overrides both; a suggestion that fails for any reason falls back to `empty-3d` rather than failing the scaffold. Dependencies are installed with **pnpm** when your machine has it and npm otherwise — the lockfile that produces is what every later command reads, so it never tells you to run the wrong one. |
195
+ | `bitmagic init [dir] [--template <id>] [--name <name>] [--idea "<pitch>"] [--art-style <voxel\|low-poly>] [--env <env>] [--force]` | Scaffold a new project: mints a game, downloads the vendored engine, writes `AGENTS.md`, `GAME-DESIGN.md` and templates. Starts from `empty-3d` unless told otherwise. `--idea` seeds the design doc's pitch **and** picks the template whose camera and controls fit it — "a doom-like corridor shooter" scaffolds `first-person`, "jump between floating islands" scaffolds `sidescroller`. `--template` overrides both; a suggestion that fails for any reason falls back to `empty-3d` rather than failing the scaffold. Dependencies are installed with **pnpm** when your machine has it and npm otherwise — the lockfile that produces is what every later command reads, so it never tells you to run the wrong one. |
196
196
  | `bitmagic import <export.zip> [dir] [--name <name>] [--new-game] [--no-rehost] [--env <env>] [--force]` | Convert a game exported from the **web Creator** into a CLI project. Takes the `.zip` the Creator's "Export game" button downloads, writes its `source/` into `src/work/`, vendors the current engine around it, and produces the same project `init` does. **Keeps the game's id**, so `publish` updates the game you already have and every asset URL keeps resolving; `--new-game` forks instead, minting a fresh game and rewriting the id through `game.json` and `world.json`. Assets belonging to a **different** environment than the project are copied onto this one and the references rewritten, so a dev game imported to prod serves its own assets (`--no-rehost` leaves them). Defaults the directory to `./<gameId>`. See **Importing a Creator game** below. |
197
197
  | `bitmagic check` | Typecheck (`tsc --noEmit`) against the vendored engine. Fast; does not prove the game runs. |
198
198
  | `bitmagic dev [--port <port>] [--editor-port <port>] [--no-open] [--mobile] [--mobile-http]` | Build, then serve everything and keep it running: `tsc --watch`, `vite`, and a **Game / Editor** view that reloads itself when the project changes, and shows where the game is published (with a QR code) once it has been. Every project on the machine gets its own fixed pair of ports and keeps them — 3010/3011 for the first, 3020/3021 for the next, and so on (see **One project, one set of ports** below). Opens the view in your browser, unless the tab from the previous run is still there, in which case that tab reloads itself instead; `--no-open` (or `BITMAGIC_NO_OPEN=1`) skips opening altogether. `--mobile` also serves the game to phones on your network, over https, and prints a QR code for that. The one command to leave open. See **The dev view** below. |
@@ -202,8 +202,8 @@ browser reloads itself on every change and the assets panel shows each generatio
202
202
  | `bitmagic publish [--visibility public\|private] [--name <name>] [--description <desc>] [--force] [--single-file] [--no-qr]` | Verify-gate, build if needed, and upload straight to GCS. `--single-file` publishes the offline bundle described under `bitmagic build`; switching the flag between runs always rebuilds, since it changes nothing on disk for the reuse check to notice. **Keeps the game's current visibility**; a new game is private until published with `--visibility public`. Shows the published URL as a QR code so you can scan it onto a phone — drawn in the terminal, or written to `.bitmagic/publish-qr.png` when it cannot be. `--no-qr` skips both. |
203
203
  | `bitmagic self-update [--tag latest\|dev]` | Update the **CLI itself** to the newest build of its line, installing into the place this CLI actually lives rather than wherever the `npm` on your `PATH` would put it — which under nvm is routinely somewhere else. Reads the project's line from `bitmagic.json` when there is one, so it also fixes a wrong-line install; `--tag` overrides. Verifies the version on disk moved, and refuses (naming the path) for an `npx` run, a source checkout, or an install shape it cannot recognise. Not to be confused with `upgrade`, which is about the project's engine. |
204
204
  | `bitmagic upgrade [--engine <version>] [--force]` | Refresh vendored platform files and the CLI's own skills to the currently published engine. Refuses a dirty git tree unless `--force`. Also records the project's `environment` in `bitmagic.json` if it has none yet — the engine it just vendored came from that api-server — and says so when it does. |
205
- | `bitmagic generate <skybox\|background\|block-type\|sound\|music\|image\|video\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block, `video` makes a short clip for a cutscene (played with `engine.playVideo("<id>")`) — see below. `skybox` and `background` write the **same** field: pick one. |
206
- | `bitmagic assets add <file> [--name <n>] [--asset-id <id>] [--keep-glb] [--voxel-size <m>] [--max-voxel-size <m>] [--height <m>] [--hollow\|--solid] [--force]` | Upload **your own** file — `.glb`, `.vxl`, `.png/.jpg/.webp`, `.opus/.ogg/.m4a/.mp3`, `.json` — into the game's assets and write its entry into `src/work/world.json`. A `.glb` is voxelized in a headless browser (Chrome + a free port in 3000–3199, like `prop`) unless `--keep-glb`. Free — no sparks. See **Adding your own assets** below. |
205
+ | `bitmagic generate <skybox\|background\|block-type\|sound\|music\|image\|video\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path; a low-poly project keeps the mesh as a `glb` asset — see **Art style**), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block, `video` makes a short clip for a cutscene (played with `engine.playVideo("<id>")`) — see **Cutscene clips** below. `skybox` and `background` write the **same** field: pick one. |
206
+ | `bitmagic assets add <file> [--name <n>] [--asset-id <id>] [--keep-glb\|--voxelize] [--voxel-size <m>] [--max-voxel-size <m>] [--height <m>] [--hollow\|--solid] [--force]` | Upload **your own** file — `.glb`, `.vxl`, `.png/.jpg/.webp`, `.opus/.ogg/.m4a/.mp3`, `.json` — into the game's assets and write its entry into `src/work/world.json`. A `.glb` is voxelized in a headless browser (Chrome + a free port in 3000–3199, like `prop`) unless `--keep-glb` — or unless the project is low-poly (see **Art style**). Free — no sparks. See **Adding your own assets** below. |
207
207
  | `bitmagic assets list` | The assets in `world.json`: id, name, type, size, placed instances, and total references. Local, no network. |
208
208
  | `bitmagic assets materials <assetId> [--set <file.json>] [--auto] [--clear]` | Show what an asset's voxels look like, or say what they are **made of** — so a sword's steel blade shines and its leather grip does not. With no flag it prints the asset's colour groups; `--set` applies your verdicts; `--auto` lets a model decide (what `generate prop` and `generate model` already do on their own); `--clear` removes them. Free — no sparks, nothing generated. See **Voxel materials** below. |
209
209
  | `bitmagic assets remove <assetId> [--force]` | Remove an asset from `world.json` (the uploaded file stays in storage). Refuses while anything in `world.json` still references it; `--force` removes the asset and its placed instances together. |
@@ -211,10 +211,11 @@ browser reloads itself on every change and the assets panel shows each generatio
211
211
  | `bitmagic reference <make\|accept\|show\|clear> ...` | Make a concept image of the game's world, iterate until it is right, and `accept` one — the accepted image becomes the default style reference for `generate skybox\|background\|block-type\|image`, `cover` and `forge`. Only `make` costs sparks (priced like an image). See **Reference image** below. |
212
212
  | `bitmagic judge [--genre <name>] [--platform desktop\|mobile] [--min-score <1-10>]` | Grade the screenshot `bitmagic verify` captured against a quality rubric built from `GAME-DESIGN.md` (plus an optional genre hint) with a vision model, and print a scorecard: four dimension scores and at most three actionable findings, also written to `.bitmagic/judge.json`. Informs rather than gates — exits 0 whatever the score, unless `--min-score` sets a bar (exit 4 below it). `--platform` picks which frame to grade; with no flag it grades the mobile one when the game is mobile-primary and `screenshot.mobile.png` exists, and the mobile rubric folds touch-HUD legibility, thumb reach and phone framing into the existing `readability` and `polish` dimensions rather than adding a fifth score. Warns when the project changed since the screenshot. Costs sparks (a few per run — one call to a model that reasons about the frame before it scores). |
213
213
  | `bitmagic trailer record [--seconds N] [--resolution WxH] [--flythrough <path.json>]` | Record gameplay for a trailer: opens Chrome on the running `bitmagic dev`, waits for Play, starts the engine's F9 recorder for you, stops after N seconds or your F9, and prints the recording dir (`.bitmagic/trailer/recordings/<name>/`). `--flythrough` records an authored camera path with nobody playing. |
214
- | `bitmagic trailer make [--music-prompt "..." \| --music <file> \| --no-music] [--title <t>] [--seconds 30] [--ui] [--publish] [--reset-shots]` | The one-shot: analyze the newest recording, lay a music bed (generated from a prompt — sparks — or your file), write a first cut to `trailer/shots.json` or cut the one you edited, render `<game>-trailer.mp4` (new `-rN` each run), and with `--publish` put it on the CDN and the game page. |
215
- | `bitmagic trailer analyze \| beats \| cut \| render \| merge` | The steps of `make` as separate commands: score a recording into highlight windows with contact sheets; detect a track's beat grid; turn `trailer/shots.json` into a frame-exact `edl.json`; render an EDL (`--ui` redraws the HUD, `--logo`, `--music …`); join recordings from separate sittings. |
214
+ | `bitmagic trailer make [--music-prompt "..." \| --music <file> \| --no-music] [--title <t>] [--seconds 30] [--ui] [--ui-keyframe-rate 30] [--publish] [--reset-shots]` | The one-shot: analyze the newest recording, lay a music bed (generated from a prompt — sparks — or your file), write a first cut to `trailer/shots.json` or cut the one you edited, render `<game>-trailer.mp4` (new `-rN` each run), and with `--publish` put it on the CDN and the game page. |
215
+ | `bitmagic trailer analyze \| beats \| cut \| render \| merge` | The steps of `make` as separate commands: score a recording into highlight windows with contact sheets; detect a track's beat grid; turn `trailer/shots.json` into a frame-exact `edl.json`; render an EDL (`--ui` redraws the HUD, `--ui-keyframe-rate` caps how often it may change, `--logo`, `--music …`); join recordings from separate sittings. |
216
216
  | `bitmagic trailer publish <file.mp4> [--no-attach]` | Upload a trailer (`.mp4`/`.webm`, ≤ 200 MiB, header-checked so a renamed image is refused before upload) to the CDN under `game-media/<gameId>/` and put it at the front of the game page gallery. Re-running replaces the previous trailer (remembered in `.bitmagic/trailer/published.json`); the page is capped at 12 media items. Works before the first publish — the page goes live on the first `bitmagic publish --visibility public`. `--no-attach` uploads only. |
217
217
  | `bitmagic forge --prompt "..." [--city\|--dungeon\|--platformer\|--freeform] [--name <name>] [--resume <jobId>]` | Design and bake a whole playable level into `world.json`. Costs sparks, and takes many minutes — the bake alone can take twenty. `--resume <jobId>` picks a failed run back up from the last step that finished, rather than redoing the whole thing. |
218
+ | `bitmagic tools <list\|path\|install> [bundle]` | The script bundles the CLI ships for skills that drive Blender or Python (`blender-level`, `unity-import`): what they are, where this install keeps them, and `install` to copy one into the project's `tools/`. Local, no network, no sparks. |
218
219
  | `bitmagic levels <list\|add\|rename\|set-start\|remove> ...` | Manage the levels `forge` creates: which one boots, their names, registering a bake you already have, deleting one. Local, no network, no sparks. See **Managing levels** below. |
219
220
 
220
221
  Every command exits non-zero on failure with a single-line message (no stack trace) and, where a
@@ -430,6 +431,7 @@ my-game/
430
431
  <name>/ # that recording's analysis.json, sheets/, beats.json, music.mp3, edl.json and renders
431
432
  published.json # the trailer's CDN URL after bitmagic trailer publish
432
433
  previews/ # rendered asset thumbnails, cached so they are drawn once
434
+ videos/<assetId>.png # 8 frames from a generated clip, tiled — what `generate video` leaves to look at
433
435
  build/
434
436
  index.html # bitmagic build's output bundle
435
437
  manifest.json # engineVersion, fingerprint, bytes, sha256, builtAt
@@ -503,6 +505,30 @@ Re-running on an unchanged design generates nothing and costs nothing — it rep
503
505
  cover. Edit the design and run again, or pass `--force`, to replace it. Because it writes
504
506
  `world.json`, it invalidates a passing verify: run it before `bitmagic verify`, not after.
505
507
 
508
+ ## Cutscene clips
509
+
510
+ `bitmagic generate video --prompt "the castle gates swing open, dust drifting"` makes a short 720p
511
+ clip, writes it into `world.json` as a `type: "video"` asset, and prints the `engine.playVideo("<id>")`
512
+ call that plays it as a fullscreen overlay. `--duration` sets the length (billed per second);
513
+ `--image` animates a still you already have as the first frame, which is much slower and takes the
514
+ clip's shape from the image rather than the 16:9 default.
515
+
516
+ Because a clip is paid for before anyone can tell whether it is any good, the command leaves you
517
+ something to look at: **8 frames spread across the clip, tiled into `.bitmagic/videos/<assetId>.png`**.
518
+ Open it to check the clip actually shows what was asked for, without downloading the mp4 and writing
519
+ an ffmpeg filter by hand. It needs `ffmpeg` on `PATH`; without it the command still succeeds and
520
+ says why there are no frames. `--no-contact-sheet` skips the download.
521
+
522
+ The same pass says **whether the clip carries an audio track** — today's generated clips are silent,
523
+ so a cutscene that needs sound needs a music bed under it, and that is invisible in the frames.
524
+
525
+ To watch it move rather than as stills, `bitmagic dev`'s assets panel plays it.
526
+
527
+ One rule when you wire it up: call `engine.playVideo()` **from the Play click or later**, never
528
+ before. Without a prior user gesture the browser blocks autoplay, and the engine resolves the call
529
+ immediately — so the cutscene silently does not run. `engine/agent-docs/video-cutscenes.md` covers
530
+ that and what to freeze while a cutscene plays.
531
+
506
532
  ## Reference image
507
533
 
508
534
  When a game has a strong setting — a tropical island, a neon city — one accepted concept image can
@@ -1209,6 +1235,55 @@ booting one level's terrain under another's objects).
1209
1235
  Runtime level switching is engine API — `engine.loadLevel(levelId)` from game code. These commands
1210
1236
  manage the registry, not gameplay.
1211
1237
 
1238
+ ## Building a level in Blender
1239
+
1240
+ A hand-designed low-poly world — an interior, a station, a stylised set — can be written as a short
1241
+ Python script against the `bmlevel` library that ships in this package (`assets/blender-level/`),
1242
+ built by Blender into one GLB plus the collider / door / light / landmark JSON, audited with ray
1243
+ casts and rendered for review, then registered with `bitmagic assets add … --keep-glb` and loaded
1244
+ by the engine's `MeshLevel`. The scaffolded `building-levels-in-blender` skill walks through it;
1245
+ its first section says when that path applies (a frontier model, and Blender installed) and sends
1246
+ everything else to `bitmagic forge`.
1247
+
1248
+ ## Art style
1249
+
1250
+ A 3D game is voxel-built unless `src/work/game.json` says otherwise:
1251
+
1252
+ ```json
1253
+ { "gameName": "…", "artStyle": "low-poly" }
1254
+ ```
1255
+
1256
+ `artStyle` sits beside `physicsMode` and takes `voxel` (the default, and what an absent field
1257
+ means) or `low-poly`. `bitmagic init --art-style low-poly` writes it and pre-fills
1258
+ `GAME-DESIGN.md`'s Art direction heading; an existing project adds the line by hand. One field,
1259
+ read by everything that used to assume cubes:
1260
+
1261
+ | Command | `voxel` | `low-poly` |
1262
+ |---|---|---|
1263
+ | `bitmagic cover`, `bitmagic reference make` | MagicaVoxel-style prompt lead and tail | flat-shaded low-poly lead and tail (same image model) |
1264
+ | `bitmagic assets add x.glb` | voxelized in a headless browser | kept as a `glb` asset; `--voxelize` bakes it |
1265
+ | `bitmagic generate model` / `prop` | voxel master, baked in the browser | mesh generated, re-uploaded into the project, registered as `glb` (`--height` / the placeholder's fitBox); `--voxelize` bakes it |
1266
+ | `bitmagic generate character` | voxelized rig, library search first | the textured low-poly rig, no library; `--voxelize` overrides |
1267
+ | `bitmagic generate vehicle` | voxel | still voxel — the builder is voxel-native; the command says so |
1268
+ | `bitmagic judge` with no `## Art direction` | grades the style it sees | judges within low-poly |
1269
+ | the engine on load | sunset hemisphere light and a faint bloom | neither; a `MeshLevel` brings its own lighting |
1270
+
1271
+ In a low-poly game the textured rig IS the product, so `bitmagic generate character` forges it with
1272
+ `runware-meshy-6`: an even remesh with symmetric limbs and an enforced A-pose, rather than a TRELLIS
1273
+ reconstruction that is only as low-poly as the concept image happened to be. **That costs about
1274
+ $0.80 and six minutes per character**, against fractions of a cent and forty seconds — pass
1275
+ `--mesh-model unified-trellis` to put one back on the cheap lane. A voxel game does not get it: the
1276
+ body is rebuilt out of cubes, so the remesh, the pose and the texture are all thrown away.
1277
+
1278
+ Its wall time is why the forge job budget is 20 minutes and api-server's in-handler generation
1279
+ deadline is 29: at the old 8-minute and 280s ceilings a Meshy character was killed about three
1280
+ seconds before it finished, after being billed. Cloud Run's own 1800s request timeout was already
1281
+ high enough and is set in roleverse-infra.
1282
+
1283
+ Both commands print `Art style: … (game.json)` so a transcript shows which medium drove them, and a
1284
+ value that is neither word is reported once and treated as voxel. The web Creator does not read
1285
+ the field: its planner builds voxel games only.
1286
+
1212
1287
  ## Publishing
1213
1288
 
1214
1289
  `bitmagic publish` is two gated steps in one command:
@@ -0,0 +1,25 @@
1
+ # blender-level
2
+
3
+ The Python and Blender scripts behind the `building-levels-in-blender` skill (see
4
+ `cli/src/scaffold/blender-level-skill-files.ts`). A level is a short Python script written against
5
+ `bmlevel` — boxes, prisms, walls, doors, stairs, signage, rooms, lights, landmarks, all in game
6
+ coordinates — that Blender runs to produce one GLB (joined per material), an editable `.blend`
7
+ (named parts), a `bitmagic-mesh-level` JSON the engine's `MeshLevel` loads, and a `doors.json` in
8
+ the shape of `worldProfileData.doors[]`. Shipped here (package.json `files`) rather than rendered
9
+ into every project: most games never build a level this way, and the scripts are large. The
10
+ skill's step 1 tells an agent how to copy them into a project's `tools/blender-level/`.
11
+
12
+ | File | Runs under | Does |
13
+ |---|---|---|
14
+ | `find_blender.py` | python3 | prints the Blender executable, or how to install one |
15
+ | `bmlevel/` | Blender, or python3 for a dry run | the authoring vocabulary (`__init__.py`), the bpy-free maths and JSON writers (`core.py`), the Blender backend (`scene_bpy.py`) and the recording backend (`scene_null.py`) |
16
+ | `example_level.py` | either | a complete hut-and-courtyard level using every helper; the tests' fixture |
17
+ | `audit.py` | Blender | ray-cast audit of the COLLIDERS: floor continuity, door and route clearance, sealed envelopes, z-fighting candidates |
18
+ | `review.py` | Blender | deterministic review renders (overview, every door, every stair, every room) and a contact sheet |
19
+ | `sync_world.py` | python3 | carries the doors, spawn and `terrain.shape: 'none'` into `src/work/world.json` |
20
+ | `tests/` | python3 | `python3 -m unittest discover -s tests` — no Blender needed; `cli`'s `pnpm test` runs it |
21
+
22
+ The JSON contract is owned by the engine (`game/src/engine/meshlevel/MeshLevelSchema.ts`,
23
+ documented in `game/agent-docs/mesh-level.md`); `core.py` writes exactly that shape, and the
24
+ engine's validator is the arbiter when the two disagree. The intended end state is a
25
+ `bitmagic level build` command wrapping these once a second game has been through them.
@@ -0,0 +1,232 @@
1
+ """Ray-cast audit of a built level, run inside Blender against the COLLIDERS the engine will get
2
+ (not the art — decoration is exactly what must not be solid):
3
+
4
+ "$BLENDER" -b --python tools/blender-level/audit.py -- build/level/hut.mesh-level.json [--doors closed] [--json out.json]
5
+
6
+ Checks, all driven by the level JSON:
7
+ floor_continuity every volume: a ray down from 1 m above its floor must hit within 1.5 m on a 0.5 m grid
8
+ door_clearance every door (doors open): nothing solid across the opening at three heights
9
+ link_clearance every link: a player capsule (r 0.4, h 1.8) marched from A through the door to B never
10
+ hits a wall and always has a floor under it
11
+ sealed_envelope (--doors closed only) every roofed volume: rays up and outward from inside hit something —
12
+ no hole in a roof or wall the player could fall through
13
+ coplanar_overlap pairs of box colliders with a shared face plane and overlapping extents — z-fighting
14
+ candidates; a WARNING, not a failure
15
+
16
+ Exit 1 on any failure. `--json` also writes the full result for a script to read.
17
+ """
18
+ import json
19
+ import math
20
+ import os
21
+ import sys
22
+
23
+ import bmesh
24
+ from mathutils import Vector
25
+ from mathutils.bvhtree import BVHTree
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ from bmlevel.core import box_corners, flat_to_points, rotate_xz, to_blender # noqa: E402
29
+
30
+ argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else sys.argv[1:]
31
+ if not argv:
32
+ print(__doc__)
33
+ sys.exit(2)
34
+ LEVEL_PATH = argv[0]
35
+ DOORS_CLOSED = '--doors' in argv and argv[argv.index('--doors') + 1] == 'closed'
36
+ JSON_OUT = argv[argv.index('--json') + 1] if '--json' in argv else None
37
+ DOORS_PATH = LEVEL_PATH.replace('.mesh-level.json', '.doors.json')
38
+
39
+ level = json.load(open(LEVEL_PATH))
40
+ doors = json.load(open(DOORS_PATH)) if os.path.exists(DOORS_PATH) else []
41
+
42
+ # ---------------------------------------------------------------- BVH from colliders
43
+
44
+ verts: list[Vector] = []
45
+ faces: list[tuple] = []
46
+ BOX_FACES = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
47
+
48
+
49
+ def add_box(center, size, yaw) -> None:
50
+ base = len(verts)
51
+ verts.extend(Vector(to_blender(p)) for p in box_corners(center, size, yaw))
52
+ faces.extend(tuple(base + i for i in f) for f in BOX_FACES)
53
+
54
+
55
+ def add_hull(flat) -> None:
56
+ bm = bmesh.new()
57
+ for p in flat_to_points(flat):
58
+ bm.verts.new(to_blender(p))
59
+ bm.verts.ensure_lookup_table()
60
+ bmesh.ops.convex_hull(bm, input=list(bm.verts))
61
+ base = len(verts)
62
+ index = {v: i for i, v in enumerate(bm.verts)}
63
+ verts.extend(v.co.copy() for v in bm.verts)
64
+ faces.extend(tuple(base + index[v] for v in f.verts) for f in bm.faces)
65
+ bm.free()
66
+
67
+
68
+ boxes = []
69
+ for c in level['colliders']:
70
+ if c['shape'] == 'box':
71
+ add_box(c['position'], c['size'], c.get('yaw', 0.0))
72
+ boxes.append(c)
73
+ else:
74
+ add_hull(c['vertices'])
75
+ if DOORS_CLOSED:
76
+ for d in doors:
77
+ p = d['position']
78
+ add_box((p['x'], p['y'], p['z']), (d['width'], d['height'], d['thickness']), d.get('rotationY', 0.0))
79
+ bvh = BVHTree.FromPolygons(verts, faces) if faces else None
80
+
81
+
82
+ def cast(origin, direction, limit):
83
+ """Ray in GAME coordinates; returns hit distance or None."""
84
+ if bvh is None:
85
+ return None
86
+ o = Vector(to_blender(origin))
87
+ d = Vector(to_blender(direction))
88
+ hit = bvh.ray_cast(o, d, limit)
89
+ return hit[3] if hit[0] is not None else None
90
+
91
+
92
+ # ---------------------------------------------------------------- checks
93
+
94
+ results: dict[str, dict] = {}
95
+
96
+
97
+ def record(check: str, ok: bool, detail: dict) -> None:
98
+ r = results.setdefault(check, {'checks': 0, 'failures': []})
99
+ r['checks'] += 1
100
+ if not ok:
101
+ r['failures'].append(detail)
102
+
103
+
104
+ def volume_grid(v, step=0.5, inset=0.35):
105
+ px, py, pz = v['position']
106
+ sx, sy, sz = v['size']
107
+ yaw = v.get('yaw', 0.0)
108
+ xs = [x for x in frange(-sx / 2 + inset, sx / 2 - inset, step)]
109
+ zs = [z for z in frange(-sz / 2 + inset, sz / 2 - inset, step)]
110
+ for lx in xs:
111
+ for lz in zs:
112
+ wx, wz = rotate_xz(lx, lz, yaw)
113
+ yield (px + wx, py - sy / 2, pz + wz), (lx, lz)
114
+
115
+
116
+ def frange(a, b, step):
117
+ n = max(1, int(round((b - a) / step)))
118
+ return [a + (b - a) * i / n for i in range(n + 1)] if b > a else [(a + b) / 2]
119
+
120
+
121
+ volumes = {v['name']: v for v in level.get('volumes', [])}
122
+ for v in level.get('volumes', []):
123
+ for (x, floor_y, z), local in volume_grid(v):
124
+ hit = cast((x, floor_y + 1.0, z), (0, -1, 0), 2.5)
125
+ record('floor_continuity', hit is not None and hit <= 1.5, {'volume': v['name'], 'at': [round(x, 2), round(z, 2)], 'local': [round(local[0], 2), round(local[1], 2)]})
126
+
127
+ if DOORS_CLOSED:
128
+ for v in level.get('volumes', []):
129
+ if 'unroofed' in v.get('tags', []):
130
+ continue
131
+ sx, sy, sz = v['size']
132
+ yaw = v.get('yaw', 0.0)
133
+ for (x, floor_y, z), local in volume_grid(v, step=1.0):
134
+ up = cast((x, floor_y + 0.5, z), (0, 1, 0), sy + 1.0)
135
+ record('sealed_envelope', up is not None, {'volume': v['name'], 'kind': 'roof', 'at': [round(x, 2), round(z, 2)]})
136
+ for dx, dz, limit in ((1, 0, sx / 2 - local[0] + 1.0), (-1, 0, sx / 2 + local[0] + 1.0), (0, 1, sz / 2 - local[1] + 1.0), (0, -1, sz / 2 + local[1] + 1.0)):
137
+ wx, wz = rotate_xz(dx, dz, yaw)
138
+ side = cast((x, floor_y + sy / 2, z), (wx, 0, wz), limit)
139
+ record('sealed_envelope', side is not None, {'volume': v['name'], 'kind': 'wall', 'at': [round(x, 2), round(z, 2)], 'dir': [dx, dz]})
140
+ else:
141
+ for d in doors:
142
+ p = d['position']
143
+ yaw = d.get('rotationY', 0.0)
144
+ fx, fz = rotate_xz(0.0, 1.0, yaw)
145
+ rx, rz = rotate_xz(1.0, 0.0, yaw)
146
+ base_y = p['y'] - d['height'] / 2
147
+ span = d['thickness'] + 1.2
148
+ for lateral in frange(-d['width'] / 2 + 0.12, d['width'] / 2 - 0.12, 0.25):
149
+ for h in (0.3, 1.0, d['height'] - 0.25):
150
+ origin = (p['x'] + rx * lateral - fx * span / 2, base_y + h, p['z'] + rz * lateral - fz * span / 2)
151
+ hit = cast(origin, (fx, 0, fz), span)
152
+ record('door_clearance', hit is None, {'door': d['id'], 'lateral': round(lateral, 2), 'height': round(h, 2)})
153
+
154
+ door_by_id = {d['id']: d for d in doors}
155
+ for l in level.get('extras', {}).get('links', []):
156
+ a, b = volumes.get(l['from']), volumes.get(l['to'])
157
+ if not a or not b:
158
+ record('link_clearance', False, {'link': l, 'reason': 'unknown volume'})
159
+ continue
160
+ waypoints = [tuple(a['position'])]
161
+ if l.get('door'):
162
+ d = door_by_id.get(l['door'])
163
+ if not d:
164
+ record('link_clearance', False, {'link': l, 'reason': 'unknown door'})
165
+ continue
166
+ waypoints.append((d['position']['x'], d['position']['y'] - d['height'] / 2 + 0.9, d['position']['z']))
167
+ waypoints.append(tuple(b['position']))
168
+ for i in range(len(waypoints) - 1):
169
+ (x0, y0, z0), (x1, y1, z1) = waypoints[i], waypoints[i + 1]
170
+ n = max(1, int(math.hypot(x1 - x0, z1 - z0) / 0.5))
171
+ for k in range(n + 1):
172
+ t = k / n
173
+ x, z = x0 + (x1 - x0) * t, z0 + (z1 - z0) * t
174
+ # Probe for the floor from just above waist height: a door waypoint sits under its
175
+ # header, and a ray from higher up would start inside the lintel.
176
+ probe_y = y0 + (y1 - y0) * t + 0.5
177
+ floor = cast((x, probe_y, z), (0, -1, 0), 3.0)
178
+ record('link_clearance', floor is not None, {'link': [l['from'], l['to']], 'at': [round(x, 2), round(z, 2)], 'reason': 'no floor'})
179
+ if floor is None:
180
+ continue
181
+ fy = probe_y - floor
182
+ for h in (0.5, 1.5):
183
+ for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1)):
184
+ # Rays run along the march direction's perpendiculars and along it; a wall
185
+ # within capsule radius means the route squeezes through something.
186
+ blocked = cast((x, fy + h, z), (dx, 0, dz), 0.4)
187
+ record('link_clearance', blocked is None, {'link': [l['from'], l['to']], 'at': [round(x, 2), round(z, 2)], 'height': h, 'dir': [dx, dz], 'reason': 'wall within 0.4 m'})
188
+
189
+ # Z-fighting candidates: two boxes sharing a face plane with overlapping extents (axis-aligned only).
190
+ warnings = []
191
+ def faces_of(b):
192
+ if abs(b.get('yaw', 0.0)) > 1e-6:
193
+ return []
194
+ px, py, pz = b['position']
195
+ sx, sy, sz = b['size']
196
+ out = []
197
+ rect = [(px - sx / 2, px + sx / 2), (py - sy / 2, py + sy / 2), (pz - sz / 2, pz + sz / 2)]
198
+ for axis in range(3):
199
+ for sign, plane in ((-1, rect[axis][0]), (1, rect[axis][1])):
200
+ out.append((axis, sign, plane, rect))
201
+ return out
202
+ for i in range(len(boxes)):
203
+ for j in range(i + 1, len(boxes)):
204
+ for axis, sign, plane, rect in faces_of(boxes[i]):
205
+ for axis2, sign2, plane2, rect2 in faces_of(boxes[j]):
206
+ # Same plane AND same facing: two visible faces fighting. A wall standing on a floor
207
+ # (floor top against wall bottom) is a contact, not a fight.
208
+ if axis != axis2 or sign != sign2 or abs(plane - plane2) > 0.001:
209
+ continue
210
+ overlap = all(min(rect[k][1], rect2[k][1]) - max(rect[k][0], rect2[k][0]) > 0.01 for k in range(3) if k != axis)
211
+ if overlap:
212
+ warnings.append({'a': boxes[i]['name'], 'b': boxes[j]['name'], 'axis': 'xyz'[axis], 'plane': round(plane, 3)})
213
+ break
214
+
215
+ # ---------------------------------------------------------------- report
216
+
217
+ failed = 0
218
+ mode = 'doors closed' if DOORS_CLOSED else 'doors open'
219
+ print(f'AUDIT {os.path.basename(LEVEL_PATH)} ({mode}): {len(level["colliders"])} colliders, {len(doors)} doors')
220
+ for check, r in results.items():
221
+ n = len(r['failures'])
222
+ failed += n
223
+ print(f' {check:18s} {r["checks"]:6d} rays {n:4d} failed' + ('' if n == 0 else ' e.g. ' + json.dumps(r['failures'][0])))
224
+ if not results:
225
+ print(' (no volumes, doors or links to audit — declare volume()/link() in the level script)')
226
+ if warnings:
227
+ print(f' coplanar_overlap {len(warnings):4d} warning(s) — shared face planes, likely z-fighting: ' + ', '.join(f"{w['a']} / {w['b']}" for w in warnings[:6]))
228
+ if JSON_OUT:
229
+ json.dump({'mode': mode, 'results': results, 'coplanar_warnings': warnings}, open(JSON_OUT, 'w'), indent=1)
230
+ print(f' wrote {JSON_OUT}')
231
+ print('AUDIT_RESULT ' + ('PASS' if failed == 0 else f'FAIL {failed}'))
232
+ sys.exit(0 if failed == 0 else 1)