Development #4

Merged
gronod merged 8 commits from development into main 2026-09-07 23:42:23 +01:00
146 changed files with 20122 additions and 106 deletions
+320
View File
@@ -0,0 +1,320 @@
name: Build macOS Packages
# Produces, per architecture (x86_64, arm64, universal):
# TargetPrint_<tag>-<sha>-macos-<arch>.app.zip — vendored into ICCery
# TargetPrint_<tag>-<sha>-macos-<arch>.dmg — installable disk image
# plus TargetPrint_<tag>-<sha>-vendor-iccery.zip — macos-x86_64 / macos-aarch64 / macos-universal layout
on:
push:
tags:
- "v*"
branches:
- main
pull_request:
workflow_dispatch:
jobs:
build-arch:
name: Build macOS (${{ matrix.platform.name }})
runs-on: ${{ matrix.platform.os }}
env:
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
strategy:
fail-fast: false
matrix:
platform:
- name: Intel
os: macos
arch: x86_64
vendor_dir: macos-x86_64
- name: Apple Silicon
os: macos
arch: arm64
vendor_dir: macos-aarch64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Ensure workspace and git permissions
run: |
mkdir -p "${{ runner.temp }}/.config/git"
touch "${{ runner.temp }}/.config/git/ignore"
mkdir -p .git/info
touch .git/info/exclude
chmod -R u+rwX .git || true
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
- name: Enter project root
run: |
if [ -f macos/TargetPrint.xcodeproj/project.pbxproj ]; then
echo "PROJECT_ROOT=macos" >> "$GITHUB_ENV"
else
echo "PROJECT_ROOT=." >> "$GITHUB_ENV"
fi
- name: Select Xcode
env:
XCODE_APP: ${{ vars.XCODE_APP }}
run: |
ROOT="${PROJECT_ROOT:-.}"
chmod +x "$ROOT/Scripts/select_xcode.sh"
"$ROOT/Scripts/select_xcode.sh"
- name: Show toolchain
run: |
echo "HAS_FULL_XCODE=${HAS_FULL_XCODE:-unset}"
echo "DEVELOPER_DIR=${DEVELOPER_DIR:-unset}"
echo "SDKROOT=${SDKROOT:-unset}"
echo "SWIFT=${SWIFT:-unset}"
echo "xcode-select -p: $(xcode-select -p 2>/dev/null || echo unknown)"
if [ "${HAS_FULL_XCODE:-0}" = "1" ]; then
XB="${XCODEBUILD:-xcodebuild}"
"$XB" -version
else
echo "Full Xcode.app not installed; will compile with swiftc."
"${SWIFT:-$(xcrun --find swiftc)}" --version
fi
sw_vers
uname -m
- name: Set release names
id: set_env
shell: bash
run: |
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="dev"
fi
SHORT_SHA="$(git rev-parse --short HEAD)"
PREFIX="TargetPrint_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV"
echo "PREFIX=${PREFIX}" >> "$GITHUB_ENV"
echo "prefix=${PREFIX}" >> "$GITHUB_OUTPUT"
- name: Make scripts executable
working-directory: ${{ env.PROJECT_ROOT }}
run: chmod +x Scripts/*.sh
- name: Run unit tests
working-directory: ${{ env.PROJECT_ROOT }}
run: |
if [ "${HAS_FULL_XCODE:-0}" != "1" ]; then
echo "Skipping XCTest — no Xcode.app (XCTest.framework is not part of CLT)."
exit 0
fi
XB="${XCODEBUILD:-xcodebuild}"
"$XB" test \
-project TargetPrint.xcodeproj \
-scheme TargetPrint \
-destination "platform=macOS" \
-only-testing:TargetPrintTests \
-jobs "$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" \
-parallelizeTargets \
CODE_SIGNING_ALLOWED=NO \
ONLY_ACTIVE_ARCH=YES
- name: Build ${{ matrix.platform.arch }} .app
working-directory: ${{ env.PROJECT_ROOT }}
env:
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
run: |
IDENTITY="${MACOS_CODESIGN_IDENTITY:--}"
export MACOS_CODESIGN_IDENTITY="$IDENTITY"
./Scripts/build_macos.sh "${{ matrix.platform.arch }}" Release
- name: Package .app.zip and .dmg
working-directory: ${{ env.PROJECT_ROOT }}
env:
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
run: |
export MACOS_CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:--}"
./Scripts/package_release.sh "${{ matrix.platform.arch }}" "${PREFIX}"
(cd build/release-assets && shasum -a 256 "${PREFIX}.app.zip" "${PREFIX}.dmg" | tee "${PREFIX}.SHA256.txt")
- name: Upload ${{ matrix.platform.arch }} .app (for universal lipo)
uses: actions/upload-artifact@v3
with:
name: app-${{ matrix.platform.arch }}
path: ${{ env.PROJECT_ROOT }}/build/apps/${{ matrix.platform.arch }}/TargetPrint.app
- name: Upload ${{ matrix.platform.arch }} packages
uses: actions/upload-artifact@v3
with:
name: ${{ env.PREFIX }}
path: ${{ env.PROJECT_ROOT }}/build/release-assets/${{ env.PREFIX }}.*
- name: Upload Release Assets to Gitea
if: ${{ startsWith(github.ref, 'refs/tags/') && !cancelled() }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
working-directory: ${{ env.PROJECT_ROOT }}
run: |
echo "Finding release for tag ${TAG}..."
RELEASE_RESP=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
RELEASE_ID=$(echo "${RELEASE_RESP}" | grep -o '"id":[0-9]*' | head -n 1 | cut -d: -f2)
if [ -n "${RELEASE_ID}" ]; then
echo "Found Release ID: ${RELEASE_ID}. Uploading ${PREFIX} assets..."
for f in build/release-assets/${PREFIX}.app.zip \
build/release-assets/${PREFIX}.dmg \
build/release-assets/${PREFIX}.SHA256.txt; do
if [ -f "$f" ]; then
FILENAME=$(basename "$f")
echo "Uploading $FILENAME..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@$f" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
fi
done
else
echo "No existing release found for tag ${TAG}. Skipping asset upload."
fi
build-universal:
name: Build macOS (Universal)
needs: build-arch
runs-on: macos
env:
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Enter project root
run: |
if [ -f macos/TargetPrint.xcodeproj/project.pbxproj ]; then
echo "PROJECT_ROOT=macos" >> "$GITHUB_ENV"
else
echo "PROJECT_ROOT=." >> "$GITHUB_ENV"
fi
- name: Select Xcode
env:
XCODE_APP: ${{ vars.XCODE_APP }}
run: |
ROOT="${PROJECT_ROOT:-.}"
chmod +x "$ROOT/Scripts/select_xcode.sh"
"$ROOT/Scripts/select_xcode.sh"
- name: Set release names
shell: bash
run: |
if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="dev"
fi
SHORT_SHA="$(git rev-parse --short HEAD)"
PREFIX="TargetPrint_${TAG}-${SHORT_SHA}-macos-universal"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV"
echo "PREFIX=${PREFIX}" >> "$GITHUB_ENV"
echo "VENDOR_PREFIX=TargetPrint_${TAG}-${SHORT_SHA}-vendor-iccery" >> "$GITHUB_ENV"
- name: Download Intel .app
uses: actions/download-artifact@v3
with:
name: app-x86_64
path: ${{ env.PROJECT_ROOT }}/build/apps/x86_64
- name: Download Apple Silicon .app
uses: actions/download-artifact@v3
with:
name: app-arm64
path: ${{ env.PROJECT_ROOT }}/build/apps/arm64
- name: Make scripts executable
working-directory: ${{ env.PROJECT_ROOT }}
run: chmod +x Scripts/*.sh
- name: Lipo Universal 2 .app
working-directory: ${{ env.PROJECT_ROOT }}
env:
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
run: |
export MACOS_CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:--}"
# upload-artifact may nest as TargetPrint.app/ or as Contents/ at the root
for arch in x86_64 arm64; do
base="build/apps/${arch}"
if [ ! -x "${base}/TargetPrint.app/Contents/MacOS/TargetPrint" ] \
&& [ -x "${base}/Contents/MacOS/TargetPrint" ]; then
mkdir -p "${base}/TargetPrint.app"
# Move bundle contents into TargetPrint.app
shopt -s dotglob
mv "${base}/Contents" "${base}/PkgInfo" "${base}/Resources" "${base}/Info.plist" \
"${base}/_CodeSignature" "${base}/TargetPrint.app/" 2>/dev/null || true
shopt -u dotglob
fi
test -x "${base}/TargetPrint.app/Contents/MacOS/TargetPrint"
lipo -info "${base}/TargetPrint.app/Contents/MacOS/TargetPrint"
done
./Scripts/make_universal.sh
lipo -info build/apps/universal/TargetPrint.app/Contents/MacOS/TargetPrint
- name: Package universal .app.zip, .dmg, and ICCery vendor zip
working-directory: ${{ env.PROJECT_ROOT }}
env:
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
run: |
export MACOS_CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:--}"
./Scripts/package_release.sh x86_64 "TargetPrint_${TAG}-${SHORT_SHA}-macos-x86_64"
./Scripts/package_release.sh arm64 "TargetPrint_${TAG}-${SHORT_SHA}-macos-arm64"
./Scripts/package_release.sh universal "${PREFIX}"
./Scripts/vendor_bundle.sh "${VENDOR_PREFIX}"
(cd build/release-assets && shasum -a 256 \
"${PREFIX}.app.zip" "${PREFIX}.dmg" "${VENDOR_PREFIX}.zip" \
| tee "${PREFIX}.SHA256.txt")
- name: Upload universal packages
uses: actions/upload-artifact@v3
with:
name: ${{ env.PREFIX }}
path: |
${{ env.PROJECT_ROOT }}/build/release-assets/${{ env.PREFIX }}.*
${{ env.PROJECT_ROOT }}/build/release-assets/${{ env.VENDOR_PREFIX }}.zip
- name: Upload ICCery vendor tree
uses: actions/upload-artifact@v3
with:
name: ${{ env.VENDOR_PREFIX }}
path: ${{ env.PROJECT_ROOT }}/build/release-assets/vendor-iccery
- name: Upload Release Assets to Gitea
if: ${{ startsWith(github.ref, 'refs/tags/') && !cancelled() }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
working-directory: ${{ env.PROJECT_ROOT }}
run: |
echo "Finding release for tag ${TAG}..."
RELEASE_RESP=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
RELEASE_ID=$(echo "${RELEASE_RESP}" | grep -o '"id":[0-9]*' | head -n 1 | cut -d: -f2)
if [ -n "${RELEASE_ID}" ]; then
echo "Found Release ID: ${RELEASE_ID}. Uploading universal + vendor assets..."
for f in build/release-assets/${PREFIX}.app.zip \
build/release-assets/${PREFIX}.dmg \
build/release-assets/${PREFIX}.SHA256.txt \
build/release-assets/${VENDOR_PREFIX}.zip; do
if [ -f "$f" ]; then
FILENAME=$(basename "$f")
echo "Uploading $FILENAME..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@$f" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
fi
done
else
echo "No existing release found for tag ${TAG}. Skipping asset upload."
fi
+6
View File
@@ -0,0 +1,6 @@
build/
dist/
DerivedData/
xcuserdata/
*.xcuserstate
.DS_Store
+6
View File
@@ -0,0 +1,6 @@
{
"VITE_AUTH_ENABLED": "false",
"deploy": {
"database": false
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<meta charset="utf-8">
<style>
html,body{margin:0;background:#6a6a6a;width:280px;height:80px;}
.row{display:flex;gap:12px;padding:12px;align-items:center;}
.cell{background:#444;padding:4px;border-radius:4px;}
img{display:block;}
</style>
<div class="row">
<div class="cell"><img src="file:///workspace/.grok/favicon.svg.tmp" width="16" height="16"></div>
<div class="cell"><img src="file:///workspace/.grok/favicon.svg.tmp" width="32" height="32"></div>
<div class="cell"><img src="file:///workspace/.grok/favicon.svg.tmp" width="48" height="48"></div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+15
View File
@@ -0,0 +1,15 @@
> preview
> node scripts/with-app-env.mjs vite preview
╭────────── [Build Info] ─────────────╮
│ │
│ - Build Directory: .vercel/output │
│ - Date: 9/7/2026, 10:13:24 AM │
│ - Nitro Version: 3.0.260610-beta │
│ - Nitro Preset: vercel │
│ │
╰─────────────────────────────────────╯
➜ Local: http://127.0.0.1:8081/
+173
View File
@@ -0,0 +1,173 @@
# Browser QA (agent-driven only; the user is not your QA)
`AGENTS.md` § "Execution loop" states the mandatory pass. This is the menu of
capabilities and the depth judgment around it.
Everything here runs **in the sandbox** against `http://127.0.0.1:8080` — it is
**not** the user's Grok chat tab. Use whatever browser capability you have
**yourself**, so quality beats curl-only.
1. **Grok browser / computer-use / MCP browser tools**, if listed — open
`http://127.0.0.1:8080`, glance at the UI, screenshot if supported.
2. **`web_fetch`** on that URL for an HTML-only check.
3. **Playwright helper (preinstalled)** — one run loads desktop **and** mobile,
screenshots both, and prints a JSON verdict.
```bash
mkdir -p /workspace/screenshots
node scripts/browser-smoke.mjs http://127.0.0.1:8080/ /workspace/screenshots/app-builder-preview.png
# Writes app-builder-preview.png (desktop), -mobile.png, and .json (verdict).
# Then Read BOTH PNGs in one batched read if you have an image tool, and iterate if either looks wrong.
```
One run audits desktop (1280×800) **and** mobile (390×844): two PNGs (mobile
gets a `-mobile` suffix) plus a JSON verdict (per-viewport console/page errors,
body text, horizontal overflow) on stdout and next to the PNG (`.json`). It
writes under `/workspace/screenshots/` by default; pass an explicit path only
for a different name **under that directory**.
4. **`agent-browser` (preinstalled)** — the interactive pass: click, type, hold
keys, read state back out. § "Interactive QA" below; it is also what the
`controls` §5c self-test runs.
## Built output
Serve the build with `npm run preview:restart` (loopback `127.0.0.1:8081`) and
reuse the dev verdict as a baseline — the JSON reports `divergesFromBaseline`, so
you only re-read the built screenshots when it flags.
```bash
npm run preview:restart # built output on 127.0.0.1:8081 — QA only, never the live preview
node scripts/browser-smoke.mjs http://127.0.0.1:8081/ /workspace/screenshots/app-builder-built.png --baseline /workspace/screenshots/app-builder-preview.json
npm run preview:stop # frees :8081 when the built-output QA is done
```
`preview:restart` (`scripts/preview.mjs`) kills whatever holds `:8081` — the
port's owner, whoever started it — then serves the current build in the
background and returns once it answers. Use it instead of bare
`npm run preview`: `vite preview` is strictPort, so a preview left running from
an earlier build both fails a plain start and keeps serving stale output.
## Interactive QA — `agent-browser`
```bash
agent-browser open http://127.0.0.1:8080/
agent-browser snapshot -i # a11y tree with @e1 refs
agent-browser click @e1 # or: find text "Start" click
agent-browser fill "#email" you@example.com # type <text> for keystrokes
agent-browser press Enter # a tap; holding: see "Keys"
agent-browser wait --fn "window.__ready === true" # or: wait 500 (ms)
agent-browser eval "document.querySelectorAll('.card').length"
agent-browser console # or: errors (page errors)
agent-browser screenshot /workspace/screenshots/step.png
agent-browser close
```
**Any multi-verb flow goes in one `batch`.** Each invocation is a tool call of
its own, so a 10-verb flow costs ten round trips; `batch` runs the flow in one.
Reach for it by default and drop to single commands only for a one-off probe.
It takes the commands as a JSON array of string arrays on stdin, which also
sidesteps the shell quoting a flow with quotes in its `eval` would need:
```bash
agent-browser batch --bail <<'JSON'
[["open","http://127.0.0.1:8080/"],
["find","text","Add note","click"],
["fill","#title","Grocery list"],
["press","Enter"],
["wait","300"],
["eval","if (![...document.querySelectorAll('li')].some(n => n.textContent.includes('Grocery list'))) throw Error('note did not appear in the list')"],
["screenshot","/workspace/screenshots/note-added.png"]]
JSON
```
That is the shape for any app: drive the thing a user would do, then assert the
result with a throwing `eval`. It exits 0 when the note lands and 1 when it does
not, so one tool call is the whole verdict. No `close` — the page is still there
for the next check.
`--bail` stops at the first failing command; without it the later ones still
run. Either way a failed step exits non-zero, so a batch is a verdict. That is
how to keep an interactive pass cheap — one tool call instead of one per verb.
`controls` §5c is the worked example.
**`open` once.** The page outlives the command: a later invocation, batched or
not, keeps the DOM and any `window` state you set. A second `open` re-navigates
and **discards that state**, so starting every batch with one both costs a page
load and throws away the state you just built. Open at the start of a pass and
again only after a rebuild, or to reset deliberately.
**Match the depth of the pass to the app.** A page that only renders is done
once the smoke screenshots look right — the interactive verbs are for apps with
something to drive. Verify each behaviour once; re-run a check after you change
the code it covers, not otherwise.
**A `✓` from `open` is not "the page loaded".** An HTTP error page reports
`✓` too (a 404 as `✓ Error response`), so assert on page content — a `find`, a
throwing `eval` — rather than trusting the tick.
The image build asserts the core verbs stay in the CLI's subcommand list.
**Their arguments and flags are not checked** — they are upstream's, and
upstream ships ~17 releases a month, so a manual here would be stale on arrival:
if a command rejects what you typed, `agent-browser <cmd> --help` decides, not
this file.
**`@ref`s go stale.** A `@e1` is valid only for the `snapshot` that produced
it, so re-snapshot after any DOM change — navigation, a click that re-renders,
a route change — rather than reusing an old ref. It is the most common misuse.
**`find text` is case-sensitive.** It matches a substring of an element's
trimmed text — `find text "Play" click` hits `Play now` — but `start` will not
find `Start`. Copy the label from `snapshot -i`; guessing it costs a failed
command and a re-snapshot.
**Keys.** `press <key>` sends a real `event.code` (`press d``KeyD`) and is
the only single-key command `--help` documents; raw keystrokes at the current
focus, with no selector, are `keyboard type <text>`. `keydown`/`keyup` exist but
are undocumented and send **`event.code === ""`**, so a game that tracks
`event.code` never sees them — do not build a check on either. Hold keys from
inside the page instead: the game's own probe (`controls`
§5b), or dispatch the event yourself, on `document.body` with `bubbles: true`
so listeners on `document` or `window` see it — dispatching on `window` itself
does not reach a `document` listener:
```bash
agent-browser eval "document.body.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyA', key: 'a', bubbles: true }))"
```
**Falsy is not failure — throw.** `eval "false"` exits **0**; `eval "if (!cond)
throw Error('why')"` exits **1**. Throwing is what turns a check into a verdict
instead of a line of output you have to read yourself; `controls` §5c is the
worked example. Anything past one expression goes in an IIFE —
`eval "(() => { … })()"`, the form the image build asserts and the only one you
can re-run (a bare `const x = …` fails the second time with "already
declared") — or through `eval --stdin` with a heredoc, or `eval -b <base64>`,
when the quoting gets awkward. `eval` awaits a promise, so an `async` IIFE
works and its rejection still exits 1: that is how you time something in page
time rather than across command round-trips.
**Output modes.** Plain output is a line per command (`eval "1+1"` prints `2`)
and is the default for a reason: `--json` wraps each one in a repeated
`lifecycle` block, measured at ~1 KB for three commands whichever three, so it
is worth it only when you are parsing. Same for
snapshots: prefer `snapshot -i | grep -i "<thing>"` or `snapshot -s "#sel"`,
and keep a bare `snapshot` for a page you cannot otherwise navigate.
**Operating notes** (conventions, not enforcement): loopback targets and
screenshots under `/workspace/screenshots/`, the same rules as everywhere else
here; a `--session <name>` per flow if you ever run two at once; if a command
hangs, `agent-browser close` and retry.
### Fallback
`agent-browser` is the tool for interactive QA. If it fails **twice on the same
step** — daemon wedged, a command it does not support — fall back to a short
Playwright script (still installed) and say so in one line of your summary.
## How deep to go
Depth **beyond the mandatory smoke pass + screenshot read** is your judgment: a
landing page usually needs nothing more than that pass. For a game with WASD /
vehicles / flight, still verify control signs (A left / D right from a chase
cam) per `.grok/skills/controls/SKILL.md` — you don't have to play end-to-end,
but inverted A/D must not ship.
+100
View File
@@ -0,0 +1,100 @@
# Data & auth — implementation
Read this **after** `AGENTS.md` §0.5 has already said the app needs a database
and/or sign-in. The decision (auth OFF by default, the closed trigger list, "no
migrations / no `@/lib/db` unless triggered") lives in `AGENTS.md`, not here.
Full guides + snippets: the **`neon` skill** (database) and the **`auth` skill**
(sign-in), under `.grok/skills/`.
## Database (`@/lib/db`, server-only)
- `const sql = await getSql()` from `@/lib/db`. Use it **only** inside
`createServerFn` handlers / server loaders.
- Dual-mode: a regular Postgres driver (node-postgres, `pg`) when `DATABASE_URL`
is set, else a local **PGLite** fallback — so the preview always renders.
- In preview, PGLite **bootstraps at server start** (`ensureDbReady`) once the
app has migrations. Do not remove that.
- A deployed app is provisioned a real database when it ships `migrations/*.sql`
or sign-in. An app that needs one without either says so with
`"deploy": {"database": true}` in `.grok/app-env.json` — see the `neon` skill.
## Migrations
- `migrations/*.sql` is the single schema source: applied to **Neon on deploy**
(`npm run build` runs `npm run db:migrate`, so Vercel ships with the schema
ready) and to the **PGLite** preview automatically on startup.
- Add the app's tables as ordered files (`migrations/0002_*.sql`), not inline.
- An app that needs no database adds no `.sql` file, and then no migration runs
anywhere.
- The Better Auth schema sits outside that scope in `migrations/auth/` (neither
applier descends into it); the `auth` skill's "Turning sign-in on" copies it
up. Do not edit it. Applied files are keyed by **basename**, so a database
that already has `0001_auth.sql` will not re-run it.
## Server functions
`createServerFn` with input via `.validator()` — the current API on the
installed version (`.inputValidator()` is deprecated). Examples in the `neon`
and `auth` skills.
## Auth wiring (only once §0.5 says accounts)
- The app runs its **own** Better Auth at `/api/auth/*` and federates to the
shared Grok auth broker for **Google** and **X**. The only other supported
method is this app's own **email/password** (local Better Auth, off by
default — enable only via `src/lib/auth/email-password.ts`; **never rewrite**
`src/lib/auth/server.ts`). No other social providers, magic links, passkeys,
or OTP/phone.
- Two routes: `src/routes/api/auth/$.ts` (mounts Better Auth at `/api/auth/*`)
and `src/routes/login.tsx` (provider buttons via `signIn(providerId)`). Copy
the snippets from the `auth` skill.
- The live-preview popup at `/auth/popup` is served by the template Vite plugin
(`vite.config.ts``popup.server.ts`); `AGENTS.md` § "First scaffold" states
the rule about not adding a React route there.
- Read the user with `useCurrentUser()` (`@/lib/auth/use-current-user`) and gate
UI with `SignedIn` / `SignedOut` / `UserButton` (`@/lib/auth/gates`).
- Sign-in is **real even in the live preview** — it federates via a baked shared
preview client — so a visitor is signed out until they sign in. Build real
sign-in; do **NOT** scaffold demo/mock/hardcoded users.
- **Authorize every server function** with `authMiddleware`
(`@/lib/auth/middleware`): `createServerFn().middleware([authMiddleware])`
hands the handler a **verified** `context.userId` (resolved from the
same-origin session; throws when signed out). Scope **every** query by that
`user_id`. Never trust a client-sent id.
## Turning sign-in on (at scaffold or later)
`.grok/app-env.json` (`{"VITE_AUTH_ENABLED": "false"}`) is the switch, read by
`npm run dev` / `build` / `preview` alike through `scripts/with-app-env.mjs`
which is why Vite is never started outside those scripts. Follow the `auth`
skill's **"Turning sign-in on"** (flag, schema, then routes) in that order: the
routes alone render the disabled branch. `npm run check:auth`, run against a
live dev server, fails when that server and the next build disagree about the
flag (exit 0 agree, 1 diverged, 2 could not observe).
## Env
On deploy the platform injects `DATABASE_URL` + per-app auth creds; live preview
needs neither (baked preview client, PGLite fallback). Deployed behind the gate,
signed-in Grok viewers get the app session automatically from `x-grok-identity`
(see the `auth` skill — `references/grok-identity.md`); the broker federation
covers anonymous viewers and no-gate contexts.
## HARD RULE — connector / AppData API (backend only)
**Never call connector or AppData APIs from frontend code.**
| Allowed | Forbidden |
| --- | --- |
| `createServerFn({ method: "POST" }).handler` that dynamic-imports `@/lib/app-data/client.server` and calls `callTool` | Importing `@/lib/app-data/client.server` from a route component, `useEffect`, event handler, or any client module |
| UI calling that **server function** only | Browser `fetch("/__gate/app-data/…")`, `fetch` to the connectors host, or any direct CallTool from the client |
| Types/constants from `@/lib/app-data` (no network) | Putting `x-connector-access-token`, connector JWTs, or gate secrets in client state, props, or `VITE_*` env |
**Flow (required):** browser → **this app's** `createServerFn`**app backend**
SDK → public connectors host (`connectors.grok.me`, **auth required**) → gate.
Unauthenticated hits on the connectors host redirect to gate OIDC sign-in.
If you need Drive / Gmail / calendar / connector data, load the **`app-data`
skill** (`.grok/skills/app-data/SKILL.md`) and follow it exactly. Do not invent
a client-side connector client.
+40
View File
@@ -0,0 +1,40 @@
# Build & deploy target
You never trigger the deploy yourself, **but the app you build is eventually
deployed to Vercel** by the platform — so your output must build cleanly under
Vercel's process. `npm run build` must succeed and emit valid output, and code
that works under `npm run dev` but breaks a production / SSR build is a bug.
Watch for dev-only deps, server-only Node APIs run at import time, runtime
filesystem writes, and hard-coded ports / hosts / secrets.
## A passing `npm run build` does not mean the deployed app renders
The most common blank-deploy failure is
`Failed to load module script … MIME type "text/html"`: the built `index.html`
requests JS assets that 404 in prod, so the server returns the HTML fallback
(wrong MIME) and the page is blank. Fix the asset base path / build output so
`/assets/*` resolve, and ensure the SPA/SSR fallback doesn't shadow real asset
requests — then re-verify the served build renders.
If you edited source after kicking off the build, re-run `npm run build` first,
then `npm run preview:restart` — it frees `:8081` before serving, so you never
smoke the previous build's output.
## What `vite.config.ts` already does
The workspace ships a ready `vite.config.ts` and `tsconfig.json` — don't
recreate them, and don't import a vendored `vite-tanstack-config` preset. The
config:
- binds the dev port `0.0.0.0:8080`;
- pins `vite preview` to loopback `127.0.0.1:8081`, so the built output can
never be picked up as the user's live preview;
- gates `nitro({ preset: "vercel" })` on `command === "build" || isPreview`, so
it never runs in dev — left on in dev, nitro opens a second dev-server port,
which breaks the single-port 8080 live preview — but still serves the built
output under `vite preview`;
- mounts `grokPwaPlugin()`.
If you edit it, preserve both port contracts, the build/preview-gated nitro
plugin **including its `serverDir: "./server"` option** (without it the deployed
app loses the Home Screen install page), and `grokPwaPlugin()`.
+67
View File
@@ -0,0 +1,67 @@
# Generated art (2D only)
`AGENTS.md` § "Skills" states the availability rule: only call `imagine_*` tools
when they appear in your available tools list, and ship CSS / SVG / emoji /
canvas / geometric-WebGL art when they don't. This file is the pipeline detail
for when they are listed.
## Illustration
When the product needs illustration (heroes, empty states, textures, icons),
generate **2D** assets via the image tools — follow the **`imagine`** skill
(`imagine_text_to_image` / `imagine_image_to_image` / `imagine_text_to_video` /
`imagine_image_to_video` path-based stack; show results with `render_file`).
Image tools do **not** create 3D models; use geometry/glTF for interactive 3D
(`building-games`).
## Game art
- **Doctrine, not the pipeline:** for any game sprites, sheets, animations,
tiles, or UI art, load **`game-asset-core`** plus the matching specialist —
**`game-animation-frames`** (motion / loop laws), **`game-tilesets`**
(seamless tiles / transitions), **`game-character-consistency`** (turnarounds
/ variants), **`game-ui-icons`** (HUD / buttons / icon sets). These cover
engine-ready defaults, blind verify, and retry discipline — **not** a
substitute for the pipeline skills below, and not a substitute for
implementing the app.
- **2D sprites / animation sheets** (characters, walk cycles, attacks,
projectiles, FX, props): run **`generate2dsprite`** — solid **`#FF00FF`**
magenta sheets + local chroma postprocess scripts. That magenta key is
**required** by the processor; do not invent a different "keyable" color on
this path. Layer `game-asset-core` (+ `game-animation-frames` /
`game-character-consistency`) for QC.
- **Exception — abstract/geometric games** (tetris, snake, pong, breakout, and
the like) are correctly rendered procedurally. Generated sprite sheets there
are a quality regression: do **not** invoke image generation for them even
when gen tools are listed.
- **2D maps / levels / prop packs** (top-down RPG, side-scroller stages, layered
maps, collision zones): follow **`generate2dmap`**. Default engine target is
browser (`raw_canvas` / Phaser), not Godot/Unity. Tileable ground/walls → also
`game-tilesets` for seamlessness checks.
- **Denser motion from video** (optional, Grok-only): **`video2dsprite`** —
`imagine_image_to_video` → ffmpeg → magenta chroma scripts. Prefer
`generate2dsprite` for crisp production sheets, and use `video2dsprite` rather
than ad-hoc ffmpeg for the sandbox execution path.
## Share card / app identity
A custom share card is the default: open the **`og`** skill and produce a custom
`public/og.jpg` from the app's own art before you finish.
- It covers games of **every** kind and rendering tech (a DOM tic-tac-toe is
still a game), whimsical apps, creative tools, and brand-forward pages. Only
plain utilities (converters, CRUD trackers, admin dashboards) keep the
`og.grok.me` placeholder, and the favicon alone never satisfies this.
- Custom art is `public/og.jpg` **and** `"card": "custom"` in
`src/lib/og/site.json`. Bake infers custom from the file when the flag is
missing, but `brand-check` still requires the field.
- Games also set `"type": "x:game"` (X presents the unfurl as a game card) and
ship `public/x-banner.jpg` (50:11, 1200×264). `twitter:card=summary_large_image`
is layout, not the game signal.
- Title defaults to the host slug; `src/lib/og/site.json` is only needed when
the display name is not the slug, or the app is a game.
- The tags come from the PWA injector (`scripts/grok-pwa-shared.mjs`), which
overwrites anything in `__root.tsx`. Live preview emits the same tags (via
`X-Forwarded-Host`), and identity is baked at `vite build` so published Nitro
can inject without reading the workspace filesystem.
- Applies at build time, publish or not.
+44
View File
@@ -0,0 +1,44 @@
# Hibernate, revive, and follow-up turns
`AGENTS.md` § "`/workspace/startup.sh`" holds the rules for the file itself.
This is the surrounding lifecycle behaviour.
## Session shapes you may land in
- **Fresh `/workspace`** — template + `node_modules` only, no app routes, no
`startup.sh`. Scaffold, then write `startup.sh`.
- **Hibernate / revive (snapshot restore)** — the platform re-runs
`/workspace/startup.sh` if it exists. Everything else in the workspace comes
back as it was, including the app source. Your job on every turn is to leave
that file able to bring the preview back on its own.
- **Reboot / recreate** — may wipe app files back to the template. Re-scaffold
and **restore `startup.sh`** before verifying the preview.
A revive with no `startup.sh` leaves nothing listening on `:8080`, so the user
sees an empty preview pane.
## A `startup.sh` that satisfies the rules
```sh
#!/bin/sh
set -eu
cd /workspace
# :8081 is QA-only — a revive must never inherit a stale built-output preview.
# Called directly, not via npm: no node_modules needed, so nothing to wait for.
node scripts/preview.mjs stop || true
if curl -sf -o /dev/null --max-time 2 http://127.0.0.1:8080/; then
exit 0
fi
npm run dev >>/tmp/app-startup.log 2>&1 &
```
## Follow-up turns (multi-turn continuity)
- Edit in place. Do not re-scaffold unless files were wiped or the change is too
big to patch cleanly.
- Vite HMR pushes source edits to the preview instantly. Restart the dev server
**only** for `vite.config` / dependency changes — and update `startup.sh` in
the same turn if the restart command changed.
- Killing the dev server blanks the user's preview mid-session.
- After edits, re-run the smoke pass; re-read the screenshots only when the edit
touched visible UI.
+80
View File
@@ -0,0 +1,80 @@
# First-scaffold snippets
Copy-paste bodies for the four entry files `AGENTS.md` § "First scaffold"
requires. They match the **installed** TanStack Start: `src/router.tsx` is
resolved by a **named `getRouter` export**, and older
`createRouter`-default-export / `app/`-directory conventions are rejected by the
plugin.
```tsx
// src/router.tsx
import { createRouter } from "@tanstack/react-router";
import { AppErrorComponent } from "@/lib/error-component";
import { routeTree } from "./routeTree.gen"; // generated on first dev/build
export function getRouter() {
return createRouter({ routeTree, defaultErrorComponent: AppErrorComponent });
}
```
```tsx
// src/routes/__root.tsx — the document shell
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
import { AuthProvider } from "@/lib/auth/provider";
import { PreviewHostBridge } from "@/components/preview-host-bridge";
import appCss from "../styles.css?url";
const APP_NAME = "My App";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: APP_NAME },
{ name: "theme-color", content: "#000000" },
],
links: [
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
{ rel: "stylesheet", href: appCss },
{ rel: "manifest", href: "/__grok/manifest.webmanifest" },
{ rel: "apple-touch-icon", href: "/__grok/icon-180.png" },
],
}),
component: () => (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
{/* Keep this bridge — lets the Grok preview chrome drive the app; noops when not embedded. */}
<PreviewHostBridge />
<AuthProvider>
<Outlet />
</AuthProvider>
<Scripts />
</body>
</html>
),
});
```
```tsx
// src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Home });
function Home() {
return <main className="p-8">Hello</main>;
}
```
```css
/* src/styles.css */
@import "tailwindcss";
@layer base {
button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; }
}
```
+110
View File
@@ -0,0 +1,110 @@
---
name: auth
description: >
Add user accounts and sign-in to this TanStack Start app. Use when the app
needs authentication, sign-in, user accounts, protected routes, or per-user
data. Triggers on "auth", "login", "log in", "sign in", "sign up", "account",
"users", "authentication", "protected", "who is logged in", "current user",
"per-user".
metadata:
short-description: "Auth via the Grok broker (Google, X) or local email/password — no other methods supported"
user-invocable: false
---
# Auth
This app runs its **own** [Better Auth](https://better-auth.com) at
`/api/auth/*`, federating to the shared **Grok auth broker** (`auth.grok.me`)
via the `genericOAuth` plugin. This template wires **Google** and **X**.
**Supported sign-in methods — use ONLY these three; nothing else is supported:
Google, X, and email/password.** No other social/OAuth provider (GitHub, Apple,
Discord, …), no magic links, passkeys, OTP, phone/SMS, or anonymous sign-in. Do
not add entries to `GROK_PROVIDERS`. Method detail and the email/password switch
(edit **only** `src/lib/auth/email-password.ts`): `references/sign-in-methods.md`.
**Exception — gate viewers are signed in already; NEVER render login/re-auth
buttons for them. Connector / app-data apps: ONLY gate "Continue with Grok",
no Google/X buttons**: `references/grok-identity.md`.
**Sign-in is OFF by default** — the template ships `.grok/app-env.json` with
`{"VITE_AUTH_ENABLED": "false"}`, so only add accounts when the ask calls for
them (AGENTS.md §0.5). Switching it on is "Turning sign-in on" below.
**Once on, sign-in is REAL — including in the sandbox live preview.** Do **NOT**
scaffold demo/mock/hardcoded users. Preview: popup + baked preview client;
deployed: per-app client + `DATABASE_URL` + zero-click gate sign-in
(`references/prewired-and-env.md`).
**While OFF** (`VITE_AUTH_ENABLED=false`) a **dev user** is returned so a
non-auth app renders without a signed-in visitor — dev and preview only.
Deployed, the flag is the platform's (always `"true"`), so `requireUserId`
rejects every visitor — auth-off apps use neither it nor `authMiddleware`.
Everything is **preinstalled and pre-wired in `src/lib/auth/`** — do not
`npm install` anything; `better-auth` is the only auth package (never
`@neondatabase/*`, `@stackframe/*`, or `@clerk/*`). **Do not edit or rewrite any
file under `src/lib/auth/`** — `server.ts` least of all — except
`email-password.ts` for its one flag. Per-file map:
`references/prewired-and-env.md`.
**`/auth/popup` is already handled by the template Vite plugin**
(`vite.config.ts``popup.server.ts`): it never paints the React app. **Do NOT
create `src/routes/auth/popup.tsx`** (or any React page / client OAuth at that
path) — that shows the full app inside the popup, the common failure mode.
**Never write a `.env` / `.env.local` / `.env.example`** in this sandbox: live
preview needs **zero** env configuration and a deployed app gets its vars
injected by the platform. The knobs that exist are in
`references/prewired-and-env.md` — never expose a non-`VITE_` var to the client.
`migrations/auth/0001_auth.sql` is the Better Auth schema — **do not edit**. It
sits outside the globbed `migrations/` directory (neither applier descends), so
it is not applied to apps without sign-in; "Turning sign-in on" copies it up.
## Turning sign-in on
Do all of this — the routes alone render the disabled branch:
1. **Flag:** delete the `VITE_AUTH_ENABLED` key from `.grok/app-env.json` and
**restart the dev server**. Vite reads env at startup, so HMR will not pick
it up. `npm run dev`, `npm run build` and `npm run preview` all read that
file through `scripts/with-app-env.mjs`, so preview and the built output flip
together — never start Vite directly.
2. **Schema:** `cp migrations/auth/0001_auth.sql migrations/0001_auth.sql`, then
restart so it applies. It is tracked by basename in `_migrations`, so a
database that already has it will not re-run it.
3. **Routes:** add `src/routes/api/auth/$.ts` + `src/routes/login.tsx` — copy
both from `references/wiring.md` (the catch-all API route is what makes
`/api/auth/*` and the broker callback work).
4. **Sign out:** a login with no way out is not done — render `<UserButton />`
from `@/lib/auth/gates` (wires `signOut()`; hides sign-out for gate sessions).
5. **Existing data:** wrap the app's server functions in `authMiddleware` (an
auth-off app must not have been using it — see the `neon` skill). Rows from
before sign-in existed are **development data**: drop and recreate them
unless the user says otherwise — don't hand them to whoever signs in first.
## Building on it once it's on
- **Sign in / out:** `signIn(providerId)` and `signOut()` from
`@/lib/auth/client`; `GROK_PROVIDERS` renders the buttons. The popup,
bearer-token hand-off, and request attachment are internal — leave them alone.
Prefer `<UserButton />` (it handles the pending and failure states); `signOut()`
rejects when deployed if the server never confirms — catch it. Never
`authClient.signOut()`: it leaves the preview bearer token attached to every
later request, so the visitor stays signed in.
- **Reading the user:** `useCurrentUser()` is display-only (`null` means
*loading OR signed out*, so never redirect on it alone); guard on
`useCurrentUserState()`'s `isPending` instead. Gates (`SignedIn`, `SignedOut`,
`SignInGate`, `RedirectToSignIn`, `UserButton`) live in `@/lib/auth/gates`.
CTA hard rules, skeleton, and cookie-SSR zero-flash: `references/session-ui.md`.
- **Per-user data (mandatory):** every server function that touches per-user data
must use the prewired `authMiddleware` and scope every read **and** write to
`context.userId` — a Postgres driver has full DB access, so nothing else limits
the query. Keep `user_id` columns `TEXT`; never trust a client-supplied user id;
signed out, the middleware throws `UnauthorizedError` (401). Code and
disabled-mode semantics: `references/per-user-data.md`.
- **Security model:** headless broker, `__Host-` cookies + `trustedOrigins`, and
Fetch-Metadata sibling isolation are already wired — never weaken them to make
an error go away (`references/sign-in-methods.md` covers the model and the
"Invalid origin" fix).
@@ -0,0 +1,64 @@
# Sign in with Grok (deployed apps — zero clicks)
Behind the edge gate, every proxied request from a signed-in Grok viewer
carries an unforgeable `x-grok-identity` JWT (EdDSA, minted per request; the
gate strips any client-supplied copy). The pre-wired `gateIdentitySessions`
plugin (`src/lib/auth/gate-session.server.ts`) verifies it against the gate's
JWKS (`/__gate/identity-key`, via `src/lib/auth/gate-identity.server.ts`) and,
when the app has no session yet, materializes the Better Auth session for that
viewer automatically — no sign-in button, no redirect, no broker round-trip.
`useSession` / `useCurrentUser` simply return the Grok user.
The broker OAuth flow is the **fallback** for anonymous/public viewers and for
contexts without the gate. The live preview gets the same zero-click identity
from the in-VM preview proxy at `http://127.0.0.1:6014` (preview tokens carry
audience `preview`); the popup mechanism remains the fallback there.
## Never render sign-in or re-auth UI to a gate viewer
A gate-authenticated viewer is **already signed in**, and the gate refreshes
connector tokens on every proxied request — **the platform has no re-auth
concept**. Never render "Re-auth with Grok", "Sign in again", "Refresh
session", or a standing "Continue with Grok" button: sign-in UI may appear
only in the `app-data` skill's `login` error state, after a connector call
actually returned `loginRequired: true`.
The live preview gets the same zero-click session, so a sign-in button visible
to the owner in the preview indicates a bug (a CTA rendered while the session
check was still pending, or rendered in reaction to a data error) — fix it,
don't restyle it. For any fallback sign-in surface use `<SignInGate>` from
`@/lib/auth/gates`: it renders sign-in UI only after the session check resolved
to no user, never during loading and never from a data error.
Sign-out is also a no-op for a gate session — the next request re-materializes
it from `x-grok-identity`, an instant sign-back-in loop. `<UserButton />`
already hides its sign-out control for gate sessions; never build a custom
sign-out (or any sign-out route/handler) for gate viewers.
## Files (pre-wired — do not edit)
| File | Role |
|---|---|
| `gate-identity.server.ts` | Verifies the gate's `x-grok-identity` viewer JWT (EdDSA vs the gate JWKS; fail-closed). Server-only. |
| `gate-session.server.ts` | Better Auth plugin that turns a verified gate identity into the app session with zero clicks. Already registered in `server.ts`. |
## Env (deployer-injected)
| Var | Scope | Meaning |
|---|---|---|
| `GROK_PROJECT_ID` | server | deployed apps: enables "Sign in with Grok" (`x-grok-identity` audience check `app:<project_id>`) |
| `GROK_GATE_ORIGIN` | server | gate public origin override (JWKS + issuer pin); unset → preview mode (no `GROK_PROJECT_ID`) defaults to the in-VM proxy `http://127.0.0.1:6014` (audience `preview`), deployed mode derives it from the inbound host |
Deployed behavior: gate-authenticated viewers are signed in automatically from
`x-grok-identity`; the deployer also injects a per-app broker client +
`DATABASE_URL`, so the fallback sign-in persists identities in Postgres.
## Connector / app-data apps: gate sign-in only
When the `app-data` skill applies, the login page offers ONLY "Continue with
Grok" via the gate sign-in — the zero-click `x-grok-identity` session above, or
the gate-built `loginUrl` returned by a connector call. Do not wire Google/X
buttons for these apps: a broker login can mint an identity that is not the
gate viewer the connector data belongs to. The three-method rule applies to
apps without connector data. Still no new `GROK_PROVIDERS` entries, still never
edit `src/lib/auth/`.
@@ -0,0 +1,49 @@
# Per-user data (server-side — mandatory)
Pair auth with the DB (see the `neon` skill). A regular Postgres driver has full
DB access, so **every** server function that touches per-user data must verify
the caller and scope rows to them. Use the prewired **`authMiddleware`**: it
resolves the same-origin session to a verified `context.userId` (and rejects
scripted cross-site/sibling requests) — no token threading:
```ts
import { createServerFn } from "@tanstack/react-start";
import { getSql } from "@/lib/db";
import { authMiddleware } from "@/lib/auth/middleware";
export const listTodos = createServerFn({ method: "GET" })
.middleware([authMiddleware])
.handler(async ({ context }) => {
const sql = await getSql();
// Type the row shape — a server fn's return must be provably serializable.
return sql<{ id: number; title: string; done: boolean }>`select id, title, done from todos where user_id = ${context.userId} order by id desc`;
});
// Inputs go through `.validator()` (the current API); the client passes `{ data }`:
export const addTodo = createServerFn({ method: "POST" })
.validator((title: string) => title.trim())
.middleware([authMiddleware])
.handler(async ({ context, data: title }) => {
if (!title) return;
const sql = await getSql();
await sql`insert into todos (user_id, title) values (${context.userId}, ${title})`;
});
// mutations must scope writes too: `... where id = ${id} and user_id = ${context.userId}`
```
Call these from **client code** (effects, event handlers, React Query) — that's
where `Sec-Fetch-Site: same-origin` holds:
```ts
useEffect(() => { listTodos().then(setTodos).catch(() => setTodos([])); }, []);
```
Semantics: signed out → the middleware throws `UnauthorizedError` (message
`"Unauthorized"`, `status` 401 — match it to send the visitor to sign-in), in the
live preview too (real auth). With auth disabled (`VITE_AUTH_ENABLED=false`) it
resolves the dev user (`"dev-user"`) in dev and preview only — the deployed flag
comes from the deployer (today always `"true"`), so deployed it rejects every
visitor — which is why an app without sign-in must not use the middleware at all
(see the `neon` skill). Keep `user_id` columns
`TEXT` (Better Auth uses text ids; the disabled dev user is `'dev-user'`). Never
trust a client-supplied user id — only the middleware / `requireUserId()` result.
@@ -0,0 +1,54 @@
# What's pre-wired, and the env vars
## `src/lib/auth/`
| File | Use it for |
|---|---|
| `client.ts` | Browser client. `signIn(providerId)`, `signOut()`, `authEnabled`, `GROK_PROVIDERS`. |
| `server.ts` | The Better Auth instance (server-only). **Do not edit or rewrite.** Import only from `/api/auth/$`. |
| `email-password.ts` | **Only** place to enable local email/password (`emailAndPasswordEnabled = true`). |
| `popup.server.ts` | Live-preview popup handler (server-only). Already wired by the Vite plugin — do not create a route for it. |
| `providers.ts` | `GROK_PROVIDERS` — the fixed broker upstream list (Google and X only; don't add others). |
| `use-current-user.ts` | `useCurrentUser()` / `useCurrentUserState()` React hooks. |
| `gates.tsx` | `SignedIn`, `SignedOut`, `RedirectToSignIn`, `UserButton`. |
| `middleware.ts` | `authMiddleware` for server functions → verified `context.userId`. |
| `verify.server.ts` | `requireUserId()` / `getSessionUser()` (server-only) for manual wiring. |
## How each mode gets its credentials
- **Live preview** (`*.grok-sandbox.com`): the app is an embedded iframe, so
sign-in opens a **popup** (a top-level redirect to the broker can't work inside
the iframe) and federates via a baked shared **preview client**
(`src/lib/auth/preview.ts`). The handler 302s straight to the broker/upstream
login; on return the popup posts the session bearer back in a tiny HTML page.
Sessions (and email/password users) persist in the app's embedded PGLite DB —
the SAME DB as app data — and, since the iframe's cookies are partitioned,
ride that bearer token; all of it lives in `src/lib/auth`.
Restarting the preview resets the DB.
- **Deployed**: the deployer injects a per-app client + `DATABASE_URL`, so
sign-in persists identities in Postgres.
## Env vars — do **not** create a `.env` file
**Never write a `.env` / `.env.local` / `.env.example` for auth (or anything
else) in this sandbox.** Live preview sign-in works out of the box with **zero**
env configuration: the server falls back to the baked preview client in
`src/lib/auth/preview.ts`, derives the `*.grok-sandbox.com` origin per-request,
mints a process-stable session secret, and persists sessions in embedded
PGLite. Deployed apps get `GROK_AUTH_*` / `BETTER_AUTH_*` / `DATABASE_URL`
injected by the platform — still not something you write into a file.
Optional process-env knobs (platform / rare overrides only — **do not** put
these in a file you create):
| Var | Where | Purpose |
|---|---|---|
| `VITE_AUTH_ENABLED` | client | `"false"` in the shipped `.grok/app-env.json` (dev user); drop the key to turn sign-in ON. Only client-visible auth flag |
| `BETTER_AUTH_URL` | server | app's own public origin; unset in preview (origin is derived per-request) |
| `BETTER_AUTH_SECRET` | server | signs this app's own sessions (process-stable fallback in preview; survives HMR) |
| `GROK_AUTH_ISSUER` | server | the shared broker (defaults to `https://auth.grok.me`) |
| `GROK_AUTH_CLIENT_ID` / `GROK_AUTH_CLIENT_SECRET` | server | per-app client (falls back to the preview client) |
| `DATABASE_URL` | server | when deployed, Better Auth persists here (preview persists to the embedded PGLite — same DB as app data) |
Never expose a non-`VITE_` var to the client. The preview client id/secret live
server-only in `src/lib/auth/preview.ts`.
+133
View File
@@ -0,0 +1,133 @@
# Reading the user, protecting routes, and preventing flicker
## When sign-in UI may render (hard rules)
- **Connector / owner-data apps: never gate the data fetch behind a session.**
When the data comes from the app's connector grants, the fetch runs on the
gate-injected connector token — a chain that does not involve the app
session. Fetch first; use the session for personalization only (greeting,
per-user rows). Requiring sign-in before fetching adds a click that changes
nothing.
- **A sign-in CTA may render only after the session check has RESOLVED to no
user** — never while `isPending` is true (no CTA flash on load), and never
as a reaction to a data error (the `app-data` skill's error mapping owns
those states; only its `login` kind ever shows "Continue with Grok").
- **In preview, gate identity signs the owner in with zero clicks** — a
visible sign-in button for the owner in the preview is a bug to fix, not a
style choice (`grok-identity.md`).
- **Use `<SignInGate>` from `@/lib/auth/gates`** — `{ children, fallback? }`:
nothing while pending, `children` when signed in, `fallback` (or the
standard provider buttons) only once signed out is known. Do not hand-roll
sign-in CTAs from `useCurrentUser()`.
## Reading the user / protecting routes
`@/lib/auth/use-current-user` (with auth on these reflect the REAL session, so a
preview visitor is signed out until they sign in):
- `useCurrentUser()``AppUser | null` — for display. `null` means *loading OR
signed out*, so never redirect on it alone.
- `useCurrentUserState()``{ user, isPending }` — for guards: wait for
`isPending` to clear before treating `user: null` as signed out, or a hard
reload bounces signed-in users to sign-in.
**State components** from `@/lib/auth/gates`: `SignedIn`, `SignedOut`,
`SignInGate` (`{ children, fallback? }` — nothing while pending, `children`
signed in, `fallback` or the standard provider buttons only once signed out is
known; prefer it over hand-rolled sign-in CTAs), `RedirectToSignIn`,
`UserButton`. (When auth is disabled via `VITE_AUTH_ENABLED=false` they apply
dev-user semantics so a non-auth app still renders.)
```tsx
import { useCurrentUser, useCurrentUserState } from "@/lib/auth/use-current-user";
import { RedirectToSignIn, SignedIn, SignedOut, UserButton } from "@/lib/auth/gates";
function Navbar() {
const user = useCurrentUser(); // display only — null may just mean "loading"
return (
<>
<span>{user?.displayName ?? "Guest"}</span>
<SignedOut><a href="/login">Sign in</a></SignedOut>
<SignedIn><UserButton /></SignedIn>
</>
);
}
function AccountPage() {
const { user, isPending } = useCurrentUserState();
if (isPending) return null; // session still resolving
if (!user) return <RedirectToSignIn />; // client-side Navigate — not window.location
return <h1>Welcome, {user.displayName}</h1>;
}
```
Sign out with `<UserButton />` or `signOut()` from `@/lib/auth/client`.
Session loading is the **same in live preview and when deployed**: wait for
`isPending` from `useCurrentUserState()` (backed by `/api/auth/get-session`).
The only live-preview difference is **how sign-in starts** (popup + bearer hand-off
instead of a full-page OAuth redirect) — not how guests vs signed-in users are
detected. Prefer `<RedirectToSignIn />` (TanStack `<Navigate>`) over
`window.location.href = "/login"` so a signed-out redirect does not full-reload
the SPA.
## Preventing auth flicker
`useSession()` resolves on the client, so a naive UI flashes signed-out →
signed-in on load. Rules:
1. **Gate on `isPending`, not `user` alone — and render a same-sized skeleton.**
Showing the SAME placeholder while `isPending` (server render + first client
paint) makes it one clean swap (skeleton → content) with no flash and no SSR
hydration mismatch. Don't return `null` in a slot that then grows — reserve the
space:
```tsx
import { useCurrentUserState } from "@/lib/auth/use-current-user";
import { UserButton } from "@/lib/auth/gates";
function AuthSlot() {
const { user, isPending } = useCurrentUserState();
if (isPending) return <div className="h-8 w-8 animate-pulse rounded-full bg-black/10" />;
return user ? <UserButton /> : <a href="/login">Sign in</a>;
}
```
2. **Guard at a layout boundary** (nav / page shell), not in leaf components that
mount/unmount — `useSession` is one shared store, so keep one stable consumer
per region instead of re-gating everywhere.
3. **Zero-flash when deployed: SSR the session from the cookie.** On a deployed
app (and top-level navigations) the session cookie is same-origin, so the server
already knows the user on the first request — resolve it in the root route and
render the authed shell immediately:
```tsx
// src/routes/__root.tsx (excerpt)
import { createServerFn } from "@tanstack/react-start";
import { createRootRoute } from "@tanstack/react-router";
const fetchSessionUser = createServerFn({ method: "GET" }).handler(async () => {
// Cookie path only — works when deployed / on top-level loads.
const { getSessionUser } = await import("@/lib/auth/verify.server");
const u = await getSessionUser();
return u ? { id: u.id, email: u.email } : null;
});
export const Route = createRootRoute({
beforeLoad: async () => ({ sessionUser: await fetchSessionUser() }),
// Merge into the existing root — keep the existing head().
// component: prefer `sessionUser` for the FIRST paint when deployed, then
// `useCurrentUserState()` for live in-page updates.
});
```
Sign-in/out navigate, so `beforeLoad` re-runs and the context stays fresh; call
`router.invalidate()` if you change auth state without navigating.
In live preview the session often rides a bearer after popup sign-in, so cookie
SSR may still return null until the client `useSession()` runs with the bearer
attached — still gate on `isPending`, same as when deployed.
The template already enables Better Auth's `session.cookieCache`, so `/get-session`
answers from a cookie when one is present (no DB round-trip).
@@ -0,0 +1,55 @@
# Supported sign-in methods (Google, X, email/password — and nothing else)
Use **only** these three — no other method is supported:
- **Google** and **X** — federated through the Grok broker (pre-wired here). The
broker federates these two upstreams and nothing else, so do **not** add entries
to `GROK_PROVIDERS` beyond them (the broker rejects an unknown `idp`).
- **Email + password** — this app's OWN Better Auth, persisted in your database
(never the broker, never mocked). Better Auth is DB-backed in BOTH modes — real
Postgres when deployed and the embedded PGLite in the sandbox preview — so
email/password accounts are stored and survive across requests, **in preview
too**. It's off by default. Enable it by editing **only**
`src/lib/auth/email-password.ts`:
```ts
// src/lib/auth/email-password.ts
export const emailAndPasswordEnabled = true; // was false
```
**Do not edit or rewrite `src/lib/auth/server.ts`** (or any other file under
`src/lib/auth/` except `email-password.ts` for this flag). That file is
pre-wired; "fixing" it by regenerating Better Auth config breaks live-preview
sign-in.
The pre-applied schema already has the `account.password` column — no migration
needed. Then build sign-up / sign-in forms with `authClient.signUp.email(...)`
and `authClient.signIn.email(...)` from `@/lib/auth/client`.
**Do not** add `emailAndPassword` as a plugin entry (that is a syntax/type
error). **Do not** invent a new Better Auth config.
If sign-up/sign-in returns **"Invalid origin"**, do **not** disable CSRF and
do **not** edit `server.ts`. The template's `trustedOrigins` already covers
`*.grok-sandbox.com` and local loopback on port 8080 (`localhost` /
`127.0.0.1` / `[::1]`). Open the app at one of those origins (not a random
host/port).
Do **NOT** add or use anything else: no other social / OAuth providers (GitHub,
Apple, Discord, Microsoft, Facebook, …), and no magic links, passkeys, one-time
codes / OTP, phone / SMS, or anonymous sign-in.
## Security model (already handled — don't undo it)
- **Headless broker**: the broker offers the upstream sign-in methods
and holds their shared secrets; this app only holds its own per-app client
id/secret and names the upstream it wants via each provider's `idp` hint. The
broker forwards straight to Google/X. Users never see the broker.
- **`__Host-` cookies + `trustedOrigins`**: a sibling `*.grok.me` app can't toss a
`Domain=.grok.me` cookie, and Better Auth rejects cross-origin `/api/auth`
calls.
- **Sibling isolation**: `authMiddleware` rejects scripted cross-site/same-site
requests (Fetch-Metadata), so a sibling can't ride this app's session cookie
into its server functions.
- The upstream Google/X tokens live only on the broker; this app only ever gets a
broker-issued identity and mints its own local session.
+71
View File
@@ -0,0 +1,71 @@
# Wiring the routes (do this once)
**Live-preview popup is PRE-WIRED — do not create it.**
`signIn` opens `/auth/popup`; the template Vite plugin
(`authPopupPlugin` in `vite.config.ts`) serves it via `popup.server.ts`.
**Never** add `src/routes/auth/popup.tsx` (or any React page / client OAuth at
that path). Doing so loads the full app shell in the popup ("the app opened
instead of Google") — that is always wrong.
**1. Mount Better Auth** — create the catch-all API route (this is what makes
`/api/auth/*` work; the broker's OAuth callback lands here):
```ts
// src/routes/api/auth/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@/lib/auth/server";
export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
});
```
**2. Add a sign-in page** — buttons that kick off the broker flow. Import from
`@/lib/auth/client`. With the flag on, `authEnabled` is true in preview and
deployed, so the buttons show and work in the live preview; the `else` branch
shows while auth is still disabled (`VITE_AUTH_ENABLED=false`):
```tsx
// src/routes/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import { GROK_PROVIDERS, authEnabled, signIn } from "@/lib/auth/client";
export const Route = createFileRoute("/login")({ component: Login });
function Login() {
return (
<main className="grid min-h-screen place-items-center p-6">
<div className="w-full max-w-sm space-y-3">
<h1 className="text-xl font-semibold">Sign in</h1>
{authEnabled ? (
GROK_PROVIDERS.map((p) => (
<button
key={p.providerId}
type="button"
onClick={() => signIn(p.providerId, { callbackURL: "/" })}
className="w-full cursor-pointer rounded-md border border-neutral-300 px-4 py-2 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-900"
>
Continue with {p.label}
</button>
))
) : (
<p className="text-sm text-neutral-500">Sign-in is disabled.</p>
)}
</div>
</main>
);
}
```
`RedirectToSignIn` sends signed-out users to `/login` by default (override with
`<RedirectToSignIn to="/somewhere" />`). Style the page however you like — see
the `design-ui` skill.
That's it — call `signIn(providerId)` from your sign-in buttons. The popup,
bearer-token hand-off, and request attachment are all inside `src/lib/auth` +
the Vite plugin; leave them alone.
+213
View File
@@ -0,0 +1,213 @@
---
name: building-games
description: >
Build browser games and interactive/canvas/3D experiences in this TanStack
Start + React app. Use for any game, simulation, or WebGL/Canvas experience —
2D or 3D, single-player. Covers the game loop & timing, 3D orientation/camera
conventions, collision, performance, assets, audio, save, game feel, and
per-genre playbooks. For WASD / vehicle / flight **input signs and inverted
A/D**, open the **`controls`** skill — do not rely on this file or racing-kart
alone. Triggers on "game", "minecraft", "fps", "platformer", "racing",
"tetris", "snake", "shooter", "3d", "three.js", "canvas", "voxel", "physics".
metadata:
short-description: "Browser games: loop, 3D orientation, camera, perf, assets, genres"
user-invocable: false
---
# Building Games
Build a **playable, correct** browser game — not a static screenshot. A game is
just a React route with a `<canvas>` (or `<Canvas>` for R3F) plus DOM overlay UI.
Style the overlay (start screen, HUD, menus) with the **`design-ui`** skill; this
skill owns the gameplay loop and world.
**Controls / inverted A/D:** open **`.grok/skills/controls/SKILL.md`** **before**
writing WASD, steering, or flight input. Vehicle/flight demos often ship with
A/D flipped if you only read this file or a single genre playbook.
**Scope note — single-player, bots, or small P2P co-op:**
- Ship **single-player** or **single-player + AI/bots** by default.
- **28 player co-op / casual realtime** (shared cursors, party games, casual
action among friends) is supported — use the **`multiplayer-p2p` skill**
(WebRTC mesh, signaled at `/api/rtc`). Read its trust model first.
- P2P is the only supported multiplayer right now. Do not half-build sockets
that cant connect.
**References (load on demand):**
- **`controls` skill** (`../controls/`) — **required** for movement/steer/flight:
player-visible A/D, inverted-steer anti-pattern, flight ailerons, mandatory
self-test + `window.__controlsTest`. Not optional for vehicles/planes.
- `references/threejs-foundational.md` — the deep 3D/loop/perf reference. Read for 3D.
- `references/3d-libs.md` — three.js + @react-three/fiber + drei + rapier usage.
- **`threejs` skill** (`../threejs/`) — official full Three.js + TSL API dump
(`llms-full.txt`). Load for advanced materials/shaders/WebGPU/loaders; not for
simple 2D canvas games.
- `references/babylon.md` — Babylon.js, the batteries-included 3D engine alternative.
- `references/phaser.md` — Phaser 3, the default engine for 2D games.
- `references/ecs-architecture.md` — entity-component-system structure for larger games.
- `references/genres/*.md` — per-genre playbooks (fps, platformer-2d, racing-kart,
puzzle-match3-tetris, voxel-minecraft, endless-runner, topdown-twin-stick,
tower-defense, board-card-chess). Genre files **do not** replace **`controls`**.
- `references/game-feel-juice.md`, `input.md`, `audio.md`, `collision-physics.md`,
`save-persistence.md`, `procedural-generation.md`, `ai-pathfinding.md`.
- **`game-asset-core`** (+ `game-animation-frames` / `game-tilesets` /
`game-character-consistency` / `game-ui-icons`) — engine-ready 2D art defaults
and verification when generating sprites, sheets, tiles, or UI (see §6).
Pick the specific genre/topic reference for the build; this file is the universal
core for loop/world. **Input signs → `controls`.** **2D game art → `game-asset-core`.**
---
## 1. Game loop & timing (the #1 correctness issue)
- Drive the loop with the engine's RAF loop (`renderer.setAnimationLoop`, R3F
`useFrame`, or `requestAnimationFrame` for 2D canvas). **Never** `setInterval`/
`setTimeout`/`Date.now()` for game timing.
- **Scale ALL movement/animation by delta time** (seconds) so speed is frame-rate
independent (60Hz vs 144Hz). Compute delta **once per frame** and reuse it.
- three.js: use `THREE.Timer` (not `Clock``Clock.getDelta()` returns ~0 on a
second call in the same frame, a classic freeze bug).
- **Cap delta** (`min(delta, 0.1)`) so a backgrounded tab doesn't teleport things.
- **Fixed timestep for physics/gameplay:** accumulate delta and step simulation at
a fixed rate (e.g. 1/60) while rendering at display rate — prevents tunneling and
non-determinism.
## 2. Controls (delegate to the `controls` skill)
**Open `.grok/skills/controls/SKILL.md` before implementing any WASD / steer /
flight code.** That skill is the source of truth for:
- Player-visible **A = left / D = right** (chase cam, while moving forward)
- Why **`KeyA → steer` + `yaw += steer * +rate` inverts** (most common bug)
- Vehicle vs FPS (strafe ≠ steer), fixed-wing ailerons, heli/drone notes
- Mandatory self-test + `window.__controlsTest` probe
Do **not** treat `genres/racing-kart.md` as the only place steer signs live —
planes, jetskis, and mechs never open it.
**Short reminder (full detail in `controls`):**
```
// Vehicle yaw body (chase cam): A must increase yaw with this basis
forward = (-sin(yaw), 0, -cos(yaw))
// KeyA → steer = +1; yaw += steer * turnRate * speedFactor * dt
// WRONG (ships inverted): KeyA → steer = -1; yaw += steer * turnRate * dt
```
- **Pointer lock:** mouse-look only — implement WASD yourself; click-to-play
overlay; dismiss on lock.
- Track keys with held state + **dt**; unify devices via `references/input.md`.
- **Finish:** run the `controls` skill checklist. Screenshot-only is insufficient
for vehicles/flight.
## 3. 3D orientation & world objects (the "sideways/backwards" bugs)
- three.js is **right-handed, +Y up**: +X right, +Y up, +Z toward viewer. **Meshes
face +Z; cameras look Z** (the classic "camera backwards" gotcha).
- Primitives like `Cone`/`Cylinder` point **+Y** by default → rotate to align a
tip with forward (`geo.rotateX(Math.PI/2)`).
- **Orienting a mesh to face `forward`** (meshes face **+Z**): simplest correct way
is `mesh.lookAt(mesh.position.clone().add(forward))`. To build the basis by hand,
set the **+Z column to `forward`** and choose the x-axis that keeps it a *proper*
right-handed rotation (`det = +1`):
`xAxis = normalize(cross(up, forward))`, then `makeBasis(xAxis, up, forward)`.
Note this is `cross(up, forward)`**not** the movement `right = cross(forward, up)`
from §2. Targeting +Z (instead of a camera's Z) flips the x-axis sign so that
`xAxis × up = forward`; the frame stays right-handed (no mirroring). Do **not** use
`makeBasis(xAxis, up, -forward)` for a mesh — that targets forward (the *camera*
convention) so the mesh faces **backwards**.
- **Orienting a camera** to look along `forward` (cameras look **Z**): set the +Z
column to `-forward``xAxis = normalize(cross(forward, up))`, then
`makeBasis(xAxis, up, -forward)` (or just `camera.lookAt(target)`).
- Keep a consistent world `up` so objects stay upright; only rotate flat primitives
to stand up.
- Verify glTF import orientation; debug with `AxesHelper`/`ArrowHelper`. Upright
self-test: characters stand on the ground plane, not lying/sunk.
## 4. Camera must agree with movement
- Keep a **dedicated `moveForward`/`moveRight`** for movement, computed once and
never mutated by camera code (aliasing a shared temp vector makes camera and
movement disagree — a real repro bug).
- Third-person follow: `desired = playerPos + up*height + moveForward*(-followDist)`;
lerp the camera toward it (use exp-based smoothing, delta-scaled), `lookAt(player)`.
- Isolate-the-layer debug order: (1) keys register → (2) movement signs correct →
(3) camera agrees. Fix in that order.
## 5. Performance
- **Minimize draw calls** (`renderer.info.render.calls`, target <100): share
materials, `InstancedMesh`/`BatchedMesh` for repeated objects, atlases.
- **Dispose GPU resources yourself** (`geometry/material/texture.dispose()`) on
level change — three.js does not GC them. **Pool** bullets/enemies/particles.
- No per-frame allocations (reuse temp vectors). Compress textures; LOD for distance.
## 6. Assets (avoid the generated-photo trap)
- **Interactive 3D elements** (weapon viewmodels, characters, props, projectiles)
→ build from **3D geometry / glTF**, not a generated image. A flat photorealistic
JPG of a gun-in-hands used as an FPS viewmodel looks wrong, can't animate, and
(JPG has **no alpha**) renders as a black box. Parent a real 3D viewmodel to the
camera as an overlay render layer.
- Reserve image generation for **flat 2D** assets only (textures, sky/menu
backgrounds, 2D sprites, UI art). **Never** use a generated photo as a 3D mesh,
viewmodel, or character substitute — build those in 3D geometry / glTF.
Set `crossOrigin="anonymous"` on images drawn to canvas/textures.
See the **`imagine`** skill (2D only — image tools cannot produce real 3D).
- **Engine-ready game art doctrine** → open **`game-asset-core`**
(`../game-asset-core/`) for defaults + blind verify + retry discipline, then the
matching specialist: **`game-animation-frames`** (loop / motion laws),
**`game-tilesets`**, **`game-character-consistency`**, **`game-ui-icons`**.
These are **QC/doctrine**, not the export pipeline. Do not ship stick-figure
placeholders when real art is expected.
- **2D game sprites / animation sheets** → run **`generate2dsprite`**
(`.grok/skills/generate2dsprite/SKILL.md`): solid **`#FF00FF`** magenta
`imagine_text_to_image` sheets + chroma postprocess scripts (magenta is required for the
processor). Wire transparent PNGs/GIFs into Canvas/Phaser. Still apply
**`game-asset-core`** (+ animation/character specialists when relevant).
- **2D maps / levels / prop packs** → open **`generate2dmap`**
(`.grok/skills/generate2dmap/SKILL.md`). Prefer foundation-only base + separate
props/collision for playable maps. Browser default: `raw_canvas` / Phaser.
Tileable ground/walls → also **`game-tilesets`** for 2×2 seam checks.
- **Optional denser locomotion** → run **`video2dsprite`** (Grok
`imagine_image_to_video` + sandbox scripts; magenta base). Prefer `generate2dsprite`
for crisp production heroes. Use **`game-animation-frames`** for loop/flip-test
laws; prefer **`video2dsprite`** over ad-hoc ffmpeg-only harvest in this
sandbox.
## 7. Audio, save, feel
- **Audio**: unlock `AudioContext` on the first user gesture (tap-to-start) or iOS
is silent; re-resume on `visibilitychange`. (`references/audio.md`)
- **Save**: `localStorage`/IndexedDB with a `version` field + migrations.
- **Juice**: screen shake, hit-stop, eased tweens, particles — cheap, huge
perceived-quality lift. Keep presentation separate from simulation.
## 8. Mobile
- Distinguish canvas buffer size from CSS size; respect `devicePixelRatio`.
- `touch-action: none`, letterbox-fit to a base resolution, handle orientation.
- Touch controls (virtual joystick + action buttons), ≥44px targets.
---
## Stack / engine choice
- **3D → three.js**, ideally via **@react-three/fiber + drei** (fits the React
app; drei gives pointer-lock/controls/loaders) + **@react-three/rapier** for
physics/character controllers. See `references/3d-libs.md`.
- **2D →** native Canvas 2D is enough for snake/tetris/flappy/platformer; reach for
Phaser only when the genre needs it.
- These game deps are **not preinstalled**`npm install` them (and make sure they
land in `package.json` so the Vercel build has them).
## Finish criteria (before "done")
- Loads with **no console errors**; visible gameplay (not a blank canvas).
- **`controls` skill self-test passed** (A = left / D = right from chase cam
while moving forward; flip one sign if inverted). Not screenshot-only.
- 3D upright & camera-agrees self-tests pass (§3, §4).
- Runs on mobile viewport with touch controls.
- Production build (`npm run build`) renders the built output, not just dev.
- **Share / X card:** open the **`og`** skill — custom `public/og.jpg` **and**
`"type": "x:game"` in `src/lib/og/site.json`. X uses `og:type="x:game"`
to present the unfurl as a game card; do not use `twitter:card` or invent
`x:type` for this. `browser-smoke` / `brand-check` warn when canvas apps omit
the `site.json` field.
@@ -0,0 +1,64 @@
# 3D libraries for this stack (three.js + React Three Fiber + drei + rapier)
The app is React/TanStack. The idiomatic way to do 3D here is **React Three
Fiber (R3F)** — write three.js as React components inside a `<Canvas>` on a
route. It deploys like any other page.
> These are **not preinstalled**. Install and confirm they land in `package.json`:
> `npm i three @react-three/fiber @react-three/drei @react-three/rapier`
> (+ `npm i -D @types/three`). If the deploy build has no three, the game is blank.
## When to use what
- **three.js** — the engine. Raw three is fine for a self-contained canvas; prefer
R3F when the game has React UI/state around it.
- **@react-three/fiber** — renders three as React; `useFrame((state, delta) => …)`
is your game loop (delta is seconds — scale movement by it, see SKILL §1).
- **@react-three/drei** — helpers so you don't hand-roll bug-prone code:
`<PointerLockControls/>`, `useKeyboardControls`, `<OrbitControls/>`,
`useGLTF`, `<Environment/>`, `<Instances/>`, `<Stats/>`.
- **@react-three/rapier** — physics + **character controller** (capsule +
autostep + snap-to-ground). Use for FPS/platformer movement and collisions
instead of hand-rolled raycasts. `<Physics>`, `<RigidBody>`, `useRapier`.
## Minimal R3F shape
```tsx
import { Canvas, useFrame } from "@react-three/fiber";
import { PointerLockControls, useKeyboardControls } from "@react-three/drei";
function Player() {
useFrame((_, delta) => {
const d = Math.min(delta, 0.1); // cap delta (SKILL §1)
// move with a dedicated moveForward/moveRight basis (SKILL §2/§4)
});
return null;
}
export default function Game() {
return (
<Canvas camera={{ position: [0, 1.7, 5], fov: 75 }} shadows dpr={[1, 2]}>
{/* scene */}
<PointerLockControls /> {/* mouse-look ONLY — implement WASD yourself */}
<Player />
</Canvas>
);
}
```
## Gotchas (map to the SKILL universals)
- **Controls:** drei `PointerLockControls` is mouse-look only — WASD is yours
(SKILL §2). Gate `.lock()` behind a "click to play" overlay and dismiss it on lock.
- **Orientation:** meshes face +Z, camera looks Z; right-handed +Y up (SKILL §3).
- **Delta:** `useFrame` delta is seconds; cap it; fixed-step physics via rapier's
own stepping. Don't read `THREE.Clock.getDelta()` twice a frame.
- **Perf:** `<Instances>`/`InstancedMesh` for repeats; dispose on unmount (R3F
auto-disposes objects it created, but not manual textures/loaders — dispose those).
Set `dpr={[1, 2]}` to cap retina cost.
- **Mobile:** R3F sets pixel ratio via `dpr`; still add touch controls + `touch-action:none`.
## Alternatives
- **Babylon.js** (`references/babylon.md`) — batteries-included 3D (Havok physics,
inspector, SceneOptimizer). Viable if you want an all-in-one engine, but it's a
separate paradigm from React; default to three/R3F for consistency with the app.
Sources: R3F docs (https://r3f.docs.pmnd.rs), drei (https://github.com/pmndrs/drei),
react-three-rapier (https://github.com/pmndrs/react-three-rapier), three.js docs.
@@ -0,0 +1,122 @@
# Game AI & Pathfinding (A* on grids, steering, FSM, behavior trees, navmesh)
Consolidated from Red Blob Games (the canonical A*/grid reference), Craig Reynolds' steering work, and Game AI Pro / behavior-tree canon (see Sources). Focus: what an AI builder needs so enemies/agents **move sensibly and don't jitter, get stuck, or tank the frame rate.**
---
## 1. Pathfinding: A* on a grid (the default)
**A\*** finds the shortest path by expanding the node that minimizes `f = g + h`, where `g` = cost so far and `h` = heuristic estimate to goal. It's Dijkstra + a goal-directed heuristic.
Rules:
- **Heuristic must be admissible** (never overestimate true cost) or A* may return a non-optimal path.
- **4-directional grid → Manhattan distance** `|dx|+|dy|`.
- **8-directional grid → octile/Chebyshev distance** (diagonal cost ≈ 1.414). Using Manhattan on an 8-dir grid overestimates → wrong paths; using Euclidean on a 4-dir grid underestimates → slow.
- **Use a binary heap / priority queue** for the open set. A linear scan of the open set is the #1 A* perf bug — turns O(E log V) into O(V²) and stutters on big maps.
- **Track a closed set / best-g per node** so you don't reprocess nodes; update `g` if a cheaper path to a node is found.
- **Weighted tiles:** give terrain costs (mud=5, road=1) via `g`; A* naturally routes around expensive terrain.
- **Diagonal corner-cutting:** forbid diagonal moves that clip through two wall corners, or agents visually cut through walls.
- **Reconstruct the path** by following `cameFrom` parents from goal back to start, then reverse.
Related tools (Red Blob Games):
- **Breadth-First Search / Dijkstra maps ("flow fields"):** when *many* agents chase *one* goal (tower defense, RTS), compute one BFS/Dijkstra field from the goal and have every agent follow the gradient — far cheaper than one A* per agent.
- **Greedy Best-First** is faster but not optimal; A* is the balanced default.
- **JPS (Jump Point Search)** optimizes A* on uniform-cost grids.
Libraries: **PathFinding.js** (grid A*/JPS/BFS, easy), or roll your own for control.
**Path-following gotchas:** smooth the raw grid path (string-pulling / line-of-sight simplification) so agents don't zig-zag along cell centers; recompute paths sparingly (on target move / periodically, not every frame); handle "no path exists" explicitly.
---
## 2. Steering behaviors (smooth local movement)
A* gives *where* to go; **steering** (Craig Reynolds) makes agents *move there naturally* by accumulating steering forces on velocity:
- **Seek / Arrive** — move toward a target; Arrive decelerates within a slowing radius (prevents overshoot/orbit).
- **Flee / Evade** — away from a threat.
- **Pursuit** — seek the target's *predicted future* position.
- **Wander** — smooth random roaming (jitter a point on a circle ahead), not teleporting random targets.
- **Obstacle avoidance** — steer around obstacles detected by a look-ahead feeler.
- **Separation / Alignment / Cohesion** = **Flocking (boids)** for crowds/schools/swarms.
- **Path following** — follow a computed A* path smoothly rather than snapping cell-to-cell.
Rules: **combine forces** (weighted sum or priority), **clamp to max force and max speed**, apply with **delta time**. Steering handles the smooth "how"; A*/flow fields handle the strategic "where." Use both together — A* alone looks robotic; steering alone gets stuck on walls.
---
## 3. Finite State Machines (FSM) — decision-making for simple agents
- Agent is in exactly one **state** (Idle, Patrol, Chase, Attack, Flee); **transitions** fire on conditions (saw player → Chase; lost player → Patrol; low HP → Flee).
- Each state has `enter()`, `update(dt)`, `exit()`. Keep transition logic centralized/table-driven.
- Great for enemies with a handful of behaviors. **Downside:** transitions explode combinatorially (n states → up to n² transitions) — becomes spaghetti past ~68 states.
- **Hierarchical FSMs** (states containing sub-states) tame some of that.
---
## 4. Behavior Trees (BT) — scalable decision-making
When FSMs get unwieldy, use a **behavior tree**: a tree of nodes evaluated each tick, each returning **Success / Failure / Running**.
- **Composites:** **Sequence** (run children in order, fail-fast — "AND"), **Selector/Fallback** (try children until one succeeds — "OR"), **Parallel**.
- **Decorators:** Inverter, Repeat, Cooldown, Succeeder, condition guards.
- **Leaves:** Actions (MoveTo, Attack, Reload) and Conditions (IsPlayerVisible, HasAmmo).
- **`Running` is the key concept:** long actions (walking a path) return `Running` and resume next tick — don't block.
- Far more **modular/reusable/authorable** than FSMs; the industry standard for complex NPCs. Consider a lib (behaviortree.js) or a small hand-rolled implementation.
- **Utility AI / GOAP** are alternatives for emergent/goal-driven behavior at higher complexity.
**Rule:** simple enemies → FSM; complex/multi-goal NPCs → behavior tree. Don't build a BT framework for a single 3-state slime.
---
## 5. Navmesh basics (3D / open spaces)
- Grids are natural for tile games; for **open 3D/continuous** worlds, a **navigation mesh** (convex polygons covering walkable surfaces) is more efficient and gives smoother paths than a dense grid.
- Run A* over the polygon graph (portal edges), then **string-pull ("funnel algorithm")** through polygon portals for a smooth, natural path.
- In JS: **recast-navigation-js** (WASM port of industry-standard Recast/Detour) bakes navmeshes from level geometry and provides crowd/agent avoidance; three-pathfinding for a simpler Three.js navmesh follower.
- Navmeshes handle multi-level geometry (ramps, bridges) that flat grids can't; support off-mesh links (jumps, ladders).
---
## 6. Performance & correctness rules
- **Don't A* every agent every frame.** Cache paths; recompute on target movement or a throttled interval; **time-slice** pathfinding across frames or use a **web worker** for big searches so the main thread doesn't hitch.
- **Flow field / Dijkstra map** when many agents share one goal — one computation for all.
- **Binary heap** open set, **typed arrays** for large grids, reuse buffers (no per-search allocation churn).
- **Run AI on a fixed timestep** (decoupled from render) for determinism/netcode; often at a lower rate than render (e.g. 1020Hz for decisions) with steering interpolating between.
- **Stagger** AI updates across agents (LOD-AI): distant/off-screen agents think less often.
- Handle **dynamic obstacles**: mark blocked cells / re-path when the map changes; use local steering avoidance for moving agents.
---
## 7. Bug-prevention checklist
- **Non-admissible heuristic** (Euclidean on 4-dir, Manhattan on 8-dir) → non-optimal or slow paths; match heuristic to movement.
- **Linear open-set scan** → severe stutter on big maps; use a binary heap.
- **A* per agent per frame** → frame drops; cache/throttle/flow-field/worker.
- **Following raw grid path** → robotic zig-zag; smooth/string-pull and add steering.
- **No Arrive/slowing radius** → agents overshoot and orbit the target.
- **Corner-cutting diagonals** → agents clip through wall corners; forbid unsafe diagonals.
- **FSM sprawl** → unmaintainable transitions; switch to a behavior tree.
- **Blocking actions in a BT** → tree stalls; return `Running` for long actions.
- **Ignoring "no path"** → crashes/agents freeze; handle unreachable goals explicitly.
- **Not re-pathing on map change** → agents walk into new walls; invalidate cached paths.
- **AI tied to render frame rate** → non-deterministic/uneven; fixed timestep, staggered.
---
## Defaults to apply
- **Default AI stack:** **A\* on a grid** (binary-heap open set, movement-matched heuristic, weighted tiles) for "where," **steering behaviors** (seek/arrive/avoid/separation) for smooth "how," and an **FSM for simple enemies / behavior tree for complex NPCs** for "what to do."
- **Use flow fields (BFS/Dijkstra map) when many agents chase one target** (tower defense/RTS) — big perf win over per-agent A*.
- **For 3D/open worlds, generate navmeshes via recast-navigation-js**; smooth paths with the funnel algorithm.
- **Bake in the perf guardrails**: cache paths, throttle/time-slice/worker pathfinding, run AI on a fixed lower-rate tick with steering interpolation, and stagger distant agents. Always handle "no path found."
---
## Sources
- Red Blob Games — Introduction to A* (canonical, interactive): https://www.redblobgames.com/pathfinding/a-star/introduction.html ; A* Implementation Guide: https://www.redblobgames.com/pathfinding/a-star/implementation.html
- Red Blob Games — Grids/heuristics & "Pathfinding for Tower Defense" (flow fields): https://www.redblobgames.com/pathfinding/tower-defense/ ; grid pathfinding tricks: https://www.redblobgames.com/pathfinding/grids/algorithms.html
- Craig Reynolds — "Steering Behaviors For Autonomous Characters": https://www.red3d.com/cwr/steer/ ; Boids: https://www.red3d.com/cwr/boids/
- Amit Patel / Red Blob — heuristics guide: https://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html
- PathFinding.js (grid A*/JPS/BFS): https://github.com/qiao/PathFinding.js
- Jump Point Search (Harabor & Grastien): https://harablog.wordpress.com/2011/09/07/jump-point-search/
- Behavior Trees — Chris Simpson, "Behavior trees for AI: How they work": https://www.gamedeveloper.com/programming/behavior-trees-for-ai-how-they-work ; behaviortree.js: https://github.com/Calamari/BehaviorTree.js
- Game Programming Patterns — State (FSM): https://gameprogrammingpatterns.com/state.html
- Recast/Detour navmesh: https://github.com/recastnavigation/recastnavigation ; recast-navigation-js: https://github.com/isaac-mason/recast-navigation-js ; three-pathfinding: https://github.com/donmccurdy/three-pathfinding
- GOAP (Jeff Orkin, F.E.A.R.): https://alumni.media.mit.edu/~jorkin/goap.html
@@ -0,0 +1,129 @@
# Game Audio in the Browser (Web Audio API, mobile unlock, Howler.js, gain buses, spatial, latency)
Consolidated from MDN Web Audio docs and Howler.js docs (see Sources). Focus: what an AI builder needs so audio **actually plays on mobile, stays low-latency, and mixes cleanly** — the #1 audio bug in browser games is "no sound on iOS."
---
## 1. The autoplay unlock — the single most important rule
Browsers block audio until the user interacts with the page. An `AudioContext` created on page load starts in the **`"suspended"`** state and will silently play nothing until resumed.
**Rule: create/resume the AudioContext (or unlock your audio lib) from inside the FIRST real user gesture** (`click`/`touchend`/`keydown`), and call `resume()` **synchronously** in that handler's call stack (iOS Safari is strict — an `await` before `resume()` can break the gesture chain).
```js
let audioCtx;
function unlockAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume(); // call synchronously in the gesture
}
// Attach once; remove after first success. `pointerdown`/`touchend`/`keydown` all count.
window.addEventListener('pointerdown', unlockAudio, { once: true });
window.addEventListener('keydown', unlockAudio, { once: true });
```
- Show a "tap to start" / "click to play" screen so the first gesture reliably unlocks audio (and any pointer lock / fullscreen you need). Games should not expect audio before the player clicks in.
- Also **resume on `visibilitychange`/focus** — contexts can get re-suspended when a tab is backgrounded on mobile.
- Listen to the `statechange` event if you need to reflect mute/paused UI.
---
## 2. Web Audio API vs `<audio>` vs Howler
- **Don't use `<audio>` / `Audio()` elements for game SFX.** They have high latency, limited simultaneous playback, and inconsistent mobile behavior. Fine for background music streaming only.
- **Web Audio API** is the correct foundation: decode once into an `AudioBuffer`, then fire many low-latency, overlapping `AudioBufferSourceNode`s. Gives you precise scheduling, gain buses, and spatialization.
- **Howler.js** is the recommended default library — it wraps Web Audio (with `<audio>` fallback), auto-handles the **mobile unlock**, sprite support, fades, spatial audio, and pooling. Use it unless you specifically need raw Web Audio control. (Note: `AudioBufferSourceNode` is one-shot — you create a new source per play; that's normal and cheap. Howler manages this for you.)
**Preload & decode up front:** `fetch → arrayBuffer → audioCtx.decodeAudioData` during a loading screen. Decoding on first play causes an audible hitch.
---
## 3. Gain buses (music / SFX / master) — do this from the start
Route everything through a small mixer graph so you can control category volumes and a master mute:
```
source → (per-sound gain) → sfxBus ─┐
music → musicGain ────────────────┼→ masterGain → audioCtx.destination
```
```js
const master = audioCtx.createGain();
const musicBus = audioCtx.createGain();
const sfxBus = audioCtx.createGain();
musicBus.connect(master); sfxBus.connect(master); master.connect(audioCtx.destination);
```
- Set volumes with **`gain.setTargetAtTime(value, audioCtx.currentTime, 0.02)`** or `setValueAtTime`, **not** a raw `gain.value =` mid-play — abrupt jumps cause clicks/pops. Ramp fades over ~1050ms.
- Perceived loudness is logarithmic: map a 01 slider to gain with a curve (e.g. `gain = slider²`) so sliders feel linear.
- Howler equivalent: per-sound `volume`, category via `Howler` groups / separate `Howl` instances, and `Howler.volume()` for master.
---
## 4. Latency (keep audio tight)
- Prefer **Web Audio buffer sources** over `<audio>` for anything reactive — element playback latency ruins hit feedback.
- Optionally construct the context with **`{ latencyHint: 'interactive' }`** (default) for lowest latency; use `'playback'` only for pure music.
- **Reuse decoded buffers**; never `decodeAudioData` in the gameplay hot path.
- Schedule to `audioCtx.currentTime` for sample-accurate timing (e.g. rhythm games): `source.start(when)`. Don't rely on `setTimeout` for musical timing — use the Web Audio clock ("A Tale of Two Clocks").
- Avoid creating hundreds of nodes per frame; cap concurrent voices and stop/disconnect finished sources (set `source.onended` to disconnect) to avoid graph buildup.
---
## 5. Variation & layering (feel)
- **Pitch-randomize** repeated SFX so footsteps/gunfire don't sound robotic: set `source.playbackRate.value = 1 + (Math.random()*2-1)*0.1` (Howler: `rate`). Randomize volume slightly too.
- **Layer** a sound from multiple samples (thump + body + transient) — see `game-feel-juice.md`.
- **Round-robin / random pick** from a few variants of the same sound.
- Use **audio sprites** (one file, offset+duration segments) to cut HTTP requests and decode overhead — Howler supports `sprite` natively.
---
## 6. Spatial / positional audio (for 3D or top-down games)
- **`PannerNode`** positions a source in 3D relative to `audioCtx.listener`. Connect `source → panner → bus`. Update `panner.positionX/Y/Z` and `listener.positionX/Y/Z` (+ listener `forward`/`up`) each frame to match camera/player.
- `panningModel`: `'HRTF'` (realistic, headphone-friendly, costlier) or `'equalpower'` (cheap stereo). `distanceModel`: `'inverse'`/`'linear'`/`'exponential'` with `refDistance`, `maxDistance`, `rolloffFactor` for falloff.
- For simple **2D stereo pan** (left/right by screen x), a `StereoPannerNode` (pan 1..1) is cheaper and sufficient — don't reach for HRTF in a 2D game.
- Howler exposes `pos()`, `orientation()`, and `pannerAttr()` for this.
- Update positions in the render loop; set the position AudioParams via `.value` (or `setValueAtTime`) to avoid zipper noise on fast movement.
---
## 7. Formats & assets
- Use **compressed formats**: `.webm`/`.ogg` (Opus/Vorbis) with an `.mp3`/`.m4a` fallback for Safari. Howler takes an array of sources and picks a supported one.
- Keep SFX short and mono (mono halves size and works with panners); keep music stereo but compressed.
- Watch total download; long music tracks can stream via `html5: true` in Howler (uses `<audio>`, saves memory, slightly higher latency — fine for music).
---
## 8. Bug-prevention checklist
- **No sound on mobile/iOS** → didn't resume `AudioContext` synchronously inside a user gesture; add a tap-to-start unlock.
- **Silence after backgrounding tab** → context re-suspended; resume on `visibilitychange`/focus.
- **Clicks/pops on volume change or start/stop** → setting `gain.value` abruptly; ramp with `setTargetAtTime` and fade in/out a few ms.
- **Laggy hit sounds** → using `<audio>` elements; use Web Audio buffer sources.
- **Hitch on first play of a sound** → decoding at play time; preload + `decodeAudioData` during loading.
- **Robotic repeated sounds** → no pitch/volume variation; randomize `playbackRate`.
- **Growing memory / stuck voices** → not disconnecting finished sources or capping concurrent voices.
- **Works in Chrome, silent in Safari** → format not supported (provide ogg/opus **and** mp3/m4a) or async break before `resume()`.
- **No volume controls** → always expose master/music/sfx sliders + mute (accessibility + player expectation).
---
## Defaults to apply
- **Default to Howler.js** for generated games (handles unlock, sprites, fades, spatial, pooling) — fall back to raw Web Audio only when we need custom DSP/scheduling.
- **Always wire the mixer graph**: master + music + sfx gain buses with sliders and a mute, from the first commit. Map sliders through a `x²` curve.
- **Always add a "tap to start" gate** that unlocks audio (and pointer lock/fullscreen) on the first gesture, and re-resume on visibility change — kills the #1 "no audio on mobile" bug.
- **Preload + decode on the loading screen**, reuse buffers, pitch-randomize repeated SFX, ramp all gain changes. Provide ogg/opus + mp3 fallbacks.
---
## Sources
- MDN — Web Audio API Best Practices (autoplay, gesture, gain, controls): https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices
- MDN — `AudioContext.resume()`: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/resume
- MDN — `BaseAudioContext.decodeAudioData()`: https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/decodeAudioData
- MDN — `GainNode` / `AudioParam.setTargetAtTime`: https://developer.mozilla.org/en-US/docs/Web/API/GainNode , https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime
- MDN — `PannerNode` + Web audio spatialization basics: https://developer.mozilla.org/en-US/docs/Web/API/PannerNode , https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Web_audio_spatialization_basics
- MDN — `StereoPannerNode`: https://developer.mozilla.org/en-US/docs/Web/API/StereoPannerNode
- MDN — Autoplay guide for media & Web Audio: https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
- Chris Wilson — "A Tale of Two Clocks" (Web Audio scheduling): https://web.dev/articles/audio-scheduling
- Howler.js — docs & repo: https://howlerjs.com/ , https://github.com/goldfire/howler.js
- Chrome autoplay policy for Web Audio: https://developer.chrome.com/blog/autoplay/#webaudio
@@ -0,0 +1,202 @@
# Babylon.js — Deep Engine Guide (Havok physics, cameras, SceneOptimizer, WebGPU, assets, GUI, freezing)
Babylon.js is the "batteries-included" 3D engine: built-in physics, inspector, GUI, node materials, camera behaviors, SceneOptimizer, and a first-class WebGPU backend. Use it when you want an integrated engine rather than assembling Three.js + libraries.
This file assumes the general loop/orientation/perf rules and the short Babylon perf bullets in `threejs-foundational.md` and goes **much deeper** without repeating them.
---
## 1. Engine setup & the render loop
```js
import { Engine, Scene, Vector3 } from "@babylonjs/core";
const engine = new Engine(canvas, true, { preserveDrawingBuffer: false, stencil: true, powerPreference: "high-performance" });
const scene = new Scene(engine);
engine.runRenderLoop(() => scene.render());
window.addEventListener("resize", () => engine.resize());
```
- `engine.runRenderLoop` is Babylon's RAF loop; hook game logic into **`scene.onBeforeRenderObservable`** and read delta with **`engine.getDeltaTime()`** (ms) or `scene.getAnimationRatio()` (frame-rate compensation factor). Scale movement by delta as always.
- Use **`scene.onBeforeRenderObservable.add(fn)`** for per-frame logic instead of stuffing everything in the render loop closure — it's observable, disposable, and composable.
- `engine.setHardwareScalingLevel(n)` renders at 1/n resolution (n>1 = lower res, big mobile win; n<1 = supersample).
---
## 2. WebGPU (real, production-ready — prefer it with fallback)
Babylon has a mature **`WebGPUEngine`** that dramatically cuts CPU-side draw-call overhead and enables compute shaders.
```js
import { WebGPUEngine, Engine } from "@babylonjs/core";
async function createEngine(canvas) {
if (await WebGPUEngine.IsSupportedAsync) {
const e = new WebGPUEngine(canvas, { antialias: true });
await e.initAsync(); // REQUIRED before creating a Scene
return e;
}
return new Engine(canvas, true, { powerPreference: "high-performance" }); // WebGL2 fallback
}
```
- `WebGPUEngine.initAsync()` **must be awaited** before you create the scene — a top setup bug.
- **Snapshot rendering** (`engine.snapshotRendering = true`, WebGPU only) records the command stream for a static scene and replays it, slashing CPU cost for scenes whose draw list doesn't change. Set `snapshotRenderingMode` and refresh when the scene structure changes.
- WebGPU supports **compute shaders** (particles, culling, GPU picking) — not available on WebGL.
---
## 3. Cameras & Camera Behaviors (free polish)
- **ArcRotateCamera** — orbit/inspection. Set limits and inertia so it feels good: `lowerRadiusLimit`/`upperRadiusLimit`, `lowerBetaLimit`/`upperBetaLimit`, `wheelPrecision`, `panningSensibility`, `inertia`. `camera.attachControl(canvas, true)`.
- **UniversalCamera** — FPS/free camera (WASD + mouse; combines FreeCamera + touch/gamepad). `camera.applyGravity`, `camera.checkCollisions`, `camera.ellipsoid` for simple capsule collision against meshes with `mesh.checkCollisions = true` (built-in, no physics engine needed).
- **FollowCamera** — third-person chase; set `radius`, `heightOffset`, `rotationOffset`, `cameraAcceleration`, `maxCameraSpeed`.
- **Keep `camera.maxZ` as small as practical** and `minZ` as large as practical — improves depth precision, culling, and overdraw.
**Behaviors** attach polished motion for free (this is a Babylon differentiator):
- **FramingBehavior** — auto-frames a target mesh nicely (`camera.useFramingBehavior = true`).
- **BouncingBehavior** — soft bounce at radius limits.
- **AutoRotationBehavior** — idle turntable spin (great for product/menu scenes).
Enable via `camera.useAutoRotationBehavior = true` etc., then tune the behavior object.
---
## 4. Physics — Havok (V2 plugin, the current default)
Havok is the recommended physics engine (WASM, fast, deterministic-ish). Use the **Physics V2** API (`PhysicsAggregate` / `PhysicsBody` / `PhysicsShape`).
```js
import { HavokPlugin, PhysicsAggregate, PhysicsShapeType } from "@babylonjs/core";
import HavokPhysics from "@babylonjs/havok";
const havok = await HavokPhysics(); // MUST await — loads the .wasm
scene.enablePhysics(new Vector3(0, -9.81, 0), new HavokPlugin(true, havok));
// dynamic body:
new PhysicsAggregate(sphere, PhysicsShapeType.SPHERE, { mass: 1, restitution: 0.6, friction: 0.5 }, scene);
// static body: mass 0
new PhysicsAggregate(ground, PhysicsShapeType.BOX, { mass: 0 }, scene);
```
- **`await HavokPhysics()` is mandatory** and the #1 init error. First `HavokPlugin` arg (`_useDeltaForWorldStep`, usually `true`) makes physics use engine delta for smoother variable framerate.
- **Bundler gotcha (Vite/webpack):** the `HavokPhysics.wasm` file must be served. Use `await HavokPhysics({ locateFile: f => \`/assets/${f}\` })` or configure the bundler to copy the wasm.
- **PhysicsAggregate** is the easy path: it builds the matching `PhysicsShape` + `PhysicsBody` automatically. For fine control (compound shapes, sharing shapes across many bodies) build `PhysicsBody` + `PhysicsShape` yourself.
- Read/write motion: `aggregate.body.setLinearVelocity(v)`, `applyImpulse`, `setMassProperties`, `body.setMotionType(PhysicsMotionType.ANIMATED/DYNAMIC/STATIC)`.
- Collisions/triggers: `body.setCollisionCallbackEnabled(true)` + `body.getCollisionObservable().add(...)`; use `PhysicsShapeType.CONTAINER`/mesh shapes sparingly (mesh colliders are expensive — prefer primitives or convex hulls).
- **glTF imports:** apply physics to the actual mesh, not the `__root__` transform node — often re-parent or use the child mesh; otherwise transforms get double-applied.
- Debug: `new BABYLON.Debug.PhysicsViewer(scene).showBody(aggregate.body)`.
- Older engines (Cannon, Ammo, Oimo) still exist via the V1 plugin, but **Havok V2 is the recommended choice** for new games. Ammo remains an option if you need its specific features/soft bodies.
---
## 5. Asset loading — containers & glTF (do it right for level switching)
- **glTF/GLB is the format.** `import "@babylonjs/loaders";` then `await ImportMeshAsync("model.glb", scene)` (or `SceneLoader.ImportMeshAsync` / `AppendSceneAsync`). Use Draco + KTX2/Basis compression.
- **AssetContainer is the key tool for reusable/levels:** `LoadAssetContainerAsync(url, scene)` loads meshes/materials/textures **without adding them to the scene**. Then `container.addAllToScene()` / `container.removeAllFromScene()` and, crucially, **`container.dispose()`** to fully free GPU resources when unloading a level. This gives clean load/unload cycles and lets you **instantiate** copies:
- `container.instantiateModelsToScene(name => name, cloneMaterials)` clones a loaded model (with skeletons/animations) cheaply for spawning many enemies from one load.
- **AssetsManager** gives progress-tracked batch loading with tasks (mesh, texture, binary) and `onProgress`/`onFinish` — good for a loading screen.
- **Babylon does not GC GPU resources** any more than Three.js does — dispose meshes/materials/textures or (better) dispose the AssetContainer when leaving a level.
---
## 6. Materials & Node Material Editor
- **PBRMaterial** for realistic; **StandardMaterial** for simpler/cheaper. Reuse material instances across meshes (shared material = fewer state changes).
- **Node Material** — visual shader graph (Node Material Editor at nme.babylonjs.com). Export as JSON and load with `NodeMaterial.ParseFromSnippetAsync("snippetId", scene)` or from file. Great for custom effects (dissolve, water, force-fields) without writing GLSL/WGSL by hand, and it compiles to both WebGL and WebGPU.
- **Material plugins** and `material.freeze()` (see freezing below) reduce per-frame shader/uniform recompute.
---
## 7. GUI (built-in — no DOM juggling)
`import { AdvancedDynamicTexture, Button, ... } from "@babylonjs/gui";`
- **Fullscreen UI:** `AdvancedDynamicTexture.CreateFullscreenUI("ui")` — a 2D overlay in screen space for HUD/menus (buttons, sliders, text, stack panels, grids). Resolution-independent, handles pointer input, works on mobile.
- **In-world UI:** apply an ADT to a mesh's material for diegetic screens/labels; use `linkWithMesh` to attach floating labels/health bars that track 3D objects.
- Prefer Babylon GUI over hand-rolled DOM overlays for game HUDs — it integrates with the scene, scales with the canvas, and doesn't fight CSS.
---
## 8. Performance — FREEZING is the Babylon superpower
For anything static, tell Babylon to stop recomputing it. This is the highest-leverage Babylon optimization and under-used in generated code.
- **`mesh.freezeWorldMatrix()`** — mesh never moves ⇒ skip world-matrix recompute each frame. Call `unfreezeWorldMatrix()` if it needs to move again. Also set `mesh.doNotSyncBoundingInfo = true` for static meshes.
- **`material.freeze()`** — material params fixed ⇒ skip uniform/shader re-evaluation. `unfreeze()` to change it.
- **`scene.freezeActiveMeshes()`** — locks the active-mesh (culling) list; use when the set of visible meshes doesn't change (e.g., fixed camera or after setup). `unfreezeActiveMeshes()` when it changes. Huge CPU savings on large static scenes.
- **`scene.blockMaterialDirtyMechanism = true`** while bulk-creating materials to avoid repeated recompiles.
Other big levers (deeper than the three.js file's bullets):
- **`scene.performancePriority = ScenePerformancePriority.Intermediate` (or `Aggressive`)** — auto-applies a bundle of optimizations (freezes active meshes, skips some checks). `Aggressive` disables per-mesh picking/some features — verify nothing you need breaks.
- **Thin instances** (`mesh.thinInstanceAdd(matrix)` / `thinInstanceSetBuffer`) for thousands of identical static objects → one draw call, cheaper than regular instances. Regular **instances** (`mesh.createInstance()`) when you need per-instance picking/parenting.
- **`scene.autoClear = false`** (and `autoClearDepthAndStencil`) when the scene fully covers the viewport — skips the clear.
- **`scene.skipPointerMovePicking = true`** if you don't need hover/move picking (picking on every pointer move is a silent CPU cost). Set `mesh.isPickable = false` on non-interactive meshes.
- **`scene.skipFrustumClipping`**, `mesh.alwaysSelectAsActiveMesh` — micro-tune culling for known-visible meshes.
- **Merge meshes** (`Mesh.MergeMeshes([...], true, true, undefined, false, true)`) for static geometry sharing a material.
- **LOD:** `mesh.addLODLevel(distance, lowMesh)` and `addLODLevel(farDist, null)` to cull entirely far away.
- Keep **`camera.maxZ` low**; limit lights (each real-time light multiplies shader cost); use **shadow generators sparingly** with small map sizes and `RefreshRate` for static lights.
- Avoid per-frame allocations: use **`TmpVectors.Vector3[0]`** scratch vectors and the `...ToRef()` method variants (`addToRef`, `scaleToRef`) instead of returning new objects.
---
## 9. SceneOptimizer — adaptive quality to hit a target FPS
Instead of guessing settings per device, let Babylon degrade quality until it reaches a target framerate.
```js
import { SceneOptimizer, SceneOptimizerOptions } from "@babylonjs/core";
// preset tiers try progressively harder optimizations to reach the target FPS:
SceneOptimizer.OptimizeAsync(scene,
SceneOptimizerOptions.HighDegradationAllowed(60), // target 60 fps
() => console.log("reached target"),
() => console.log("could not reach target"));
```
- Presets: `LowDegradationAllowed`, `ModerateDegradationAllowed`, `HighDegradationAllowed(targetFps)`. They apply, in escalating order, optimizations like: reduce hardware scaling, merge meshes, disable shadows, reduce texture size, disable post-processes, cut particle counts.
- You can build a custom `SceneOptimizerOptions` with specific `SceneOptimization` steps and priorities. Great for shipping one build that adapts from low-end mobile to desktop automatically.
---
## 10. Inspector & profiling
- **`import "@babylonjs/inspector"; scene.debugLayer.show()`** — the in-browser inspector: scene tree, per-mesh stats, materials, textures, and a **Statistics/Performance** tab (draw calls, active meshes, frame breakdown, GPU/CPU time). This is the fastest way to find what's slow.
- Watch **draw calls** and **active meshes** counts; use the profiler to see whether you're CPU-bound (draw calls, matrix updates → freeze/instance) or GPU-bound (overdraw, shader cost, texture size → LOD/compression/fewer lights).
---
## 11. Common Babylon pitfalls (checklist)
- Forgetting `await WebGPUEngine.initAsync()` / `await HavokPhysics()` → cryptic init failures.
- Havok `.wasm` not served by the bundler → physics silently absent; use `locateFile` / copy the wasm.
- Not disposing meshes/materials/textures or the AssetContainer on level switch → GPU memory leak.
- Applying physics to a glTF `__root__` node → double transforms / wrong positions.
- Static scene running full cost because nothing is frozen → freeze world matrices, materials, active meshes.
- Picking on every pointer move (`skipPointerMovePicking` not set; meshes left `isPickable`) → CPU drain.
- Too many real-time lights / large shadow maps → shader + fill cost explosion.
- Per-frame `new Vector3()`/`Matrix` allocations → GC churn; use `TmpVectors` + `...ToRef()`.
- Huge `camera.maxZ` → z-fighting and wasted culling range.
- Mesh colliders for everything instead of primitive/convex shapes → physics perf collapse.
---
## Defaults to apply
- **Reach for Babylon when the game wants an integrated engine** (built-in physics, GUI, inspector, camera behaviors) rather than the assemble-it-yourself Three.js route.
- **Always await `initAsync()` (WebGPU) and `HavokPhysics()`**, and wire the **WebGPU-with-WebGL2-fallback** pattern by default.
- **Emit freezing by default for static content**: `freezeWorldMatrix()` on non-moving meshes, `material.freeze()` on static materials, and `scene.freezeActiveMeshes()` / `performancePriority` once the scene is set up. This is the single biggest Babylon win most generated code misses.
- **Ship `SceneOptimizer.OptimizeAsync(scene, HighDegradationAllowed(60))`** so one build adapts from low-end mobile to desktop instead of hardcoding quality.
- **Use AssetContainers** for level load/unload (`instantiateModelsToScene` to spawn many from one load; `dispose()` to free on unload) to avoid the memory leaks that plague generated 3D games.
- **Use Babylon GUI** (fullscreen ADT) for HUDs/menus instead of DOM overlays; use **camera Behaviors** (Framing/AutoRotation) for free menu/product polish.
- Set **`skipPointerMovePicking`, low `maxZ`, `isPickable=false`** on non-interactive meshes as defaults.
---
## Sources
- Babylon.js docs — Optimizing your scene (freeze APIs, autoClear, performancePriority, active meshes): https://doc.babylonjs.com/features/featuresDeepDive/scene/optimize_your_scene
- Babylon.js docs — Physics V2 / Havok (`enablePhysics`, `PhysicsAggregate`, `PhysicsBody/Shape`): https://doc.babylonjs.com/features/featuresDeepDive/physics/usingPhysicsEngine and https://github.com/BabylonJS/Documentation/blob/master/content/features/featuresDeepDive/physics/v2/usingPhysicsEngine.md
- `@babylonjs/havok` npm (init pattern, `await HavokPhysics()`, `locateFile`): https://www.npmjs.com/package/@babylonjs/havok
- Babylon.js docs — WebGPU (`WebGPUEngine`, `initAsync`, snapshot rendering): https://doc.babylonjs.com/setup/support/webGPU
- Babylon.js docs — Cameras & Camera Behaviors: https://doc.babylonjs.com/features/featuresDeepDive/cameras/camera_introduction and https://doc.babylonjs.com/features/featuresDeepDive/behaviors/cameraBehaviors
- Babylon.js docs — Asset Containers & glTF loading: https://doc.babylonjs.com/features/featuresDeepDive/importers/assetContainers and https://doc.babylonjs.com/features/featuresDeepDive/importers/loadingFileTypes
- Babylon.js docs — SceneOptimizer: https://doc.babylonjs.com/features/featuresDeepDive/scene/sceneOptimizer
- Babylon.js docs — GUI: https://doc.babylonjs.com/features/featuresDeepDive/gui/gui ; Node Material: https://doc.babylonjs.com/features/featuresDeepDive/materials/node_material/nodeMaterial
- Babylon.js docs — Inspector: https://doc.babylonjs.com/toolsAndResources/inspector
- Babylon.js forum — optimization best practices thread: https://forum.babylonjs.com/t/best-practices-for-optimizing-babylon-js-scenes-not-just-on-lower-end-devices/58688
@@ -0,0 +1,122 @@
# Collision & Physics for Browser Games (2D AABB/SAT/broadphase, swept collision, 3D Rapier/cannon, fixed timestep)
Consolidated from MDN game collision docs, Gaffer On Games, Rapier/cannon-es docs, and the SAT/swept-AABB canon (see Sources). Focus: what an AI builder needs so collisions are **correct and stable** — the classic bugs are tunneling through walls at speed, jitter, sinking into the ground, and O(n²) slowdowns.
---
## 1. Do you even need a physics engine?
- **Simple arcade games** (platformers, top-down, breakout, shooters) → hand-rolled **AABB** collision + resolution is often better: fully deterministic, no dependency, and you control feel. Most 2D games don't need Box2D/Matter.
- **Rigid-body simulation** (stacking, ragdolls, joints, realistic bouncing, vehicles) → use an engine: **2D → Matter.js / Planck.js (Box2D) / Rapier2D**; **3D → Rapier (rapier.js, WASM, fast, favored) / cannon-es / Ammo.js**.
- **Rule:** don't pull in a full rigid-body engine for a game that just needs "does the player box overlap the wall box." Physics engines add non-determinism and tuning overhead. Match the tool to the game.
---
## 2. 2D collision detection primitives
- **AABB (axis-aligned bounding box)** — the workhorse. Two boxes overlap iff they overlap on **both** axes:
```js
const hit = a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y;
```
Cheap, ideal for tile maps, most 2D gameplay. Only valid for **non-rotated** boxes.
- **Circle/distance** — for round objects: compare squared distance to summed radii (`dx*dx+dy*dy < (r1+r2)²` — avoid `sqrt`).
- **SAT (Separating Axis Theorem)** — for **rotated boxes / convex polygons**: two convex shapes don't intersect iff there exists an axis (a face normal of either shape) on which their projections don't overlap. Test all face normals; if any gap exists → no collision. The **minimum overlap axis gives the collision normal + penetration depth** (the MTV, minimum translation vector) for resolution.
- SAT only works on **convex** shapes — decompose concave shapes into convex pieces.
- For circles vs polygon, also test the axis from circle center to nearest vertex.
---
## 3. Broadphase: don't test every pair (O(n²) killer)
Checking all pairs is O(n²) — fine for dozens, catastrophic for hundreds/thousands. Split into **broadphase** (cheap culling of pairs that can't touch) → **narrowphase** (exact AABB/SAT on survivors).
- **Uniform spatial hash grid** — bucket objects into cells by position; only test objects sharing a cell. Best default for many similar-sized, evenly-distributed objects (bullets, particles, .io games). Cell size ≈ average object size (23×).
- **Quadtree** — recursively subdivide space; good for **non-uniform / clustered / varied-size** objects. Rebuild or update each frame.
- **Sort and sweep (sweep-and-prune)** — sort AABB endpoints on an axis; good when objects move coherently.
- **Rule:** the moment you have >~100 dynamic colliders, add a broadphase. Physics engines do this internally; hand-rolled collision needs it explicitly.
---
## 4. Tunneling & swept collision (the top correctness bug)
**Tunneling:** a fast object moves so far in one frame that it passes *through* a thin wall without ever overlapping it on any frame. Discrete AABB checks miss it entirely.
Fixes (in order of preference):
- **Swept collision (continuous / CCD):** instead of testing the box at its new position, test the **motion path**. **Swept AABB** computes the fraction of the frame `t (0..1)` at which the moving box first touches the target, then moves the object to exactly that contact point and resolves — no overlap ever occurs. Essential for bullets, fast platformers, thin walls.
- **Substepping / smaller fixed timestep:** break a big move into several small steps and test each. Simpler than swept, good enough for moderate speeds.
- **Raycast** fast/small projectiles (bullets) instead of moving a body — cast a ray along the travel and hit the first collider.
- **Minimum wall thickness** ≥ max per-step travel is a cheap band-aid but fragile.
- In engines, **enable CCD** on fast bodies (Rapier `setCcdEnabled(true)`, cannon-es CCD options).
---
## 5. Collision *resolution* (not just detection)
- **AABB resolution:** compute penetration on each axis; push the object out along the **axis of least penetration** (this makes walls/floors behave). Zero the velocity component on the axis you resolved (hitting the floor kills downward velocity).
- **Resolve axes separately** for platformers ("move X, resolve X, then move Y, resolve Y") — prevents catching on tile seams and gives clean wall-slide behavior.
- **Ground detection:** you're grounded if resolving downward this frame; set a small `coyoteTime` (see input skill) for feel.
- **Avoid sinking/jitter:** apply a tiny "skin"/epsilon and don't over-correct; with engines, tune restitution/friction and let the solver settle. Resting jitter usually means variable timestep or fighting corrections.
- With **SAT**, resolve along the MTV (normal × penetration depth); reflect/scale velocity along the normal for bounces.
---
## 6. Fixed timestep — the stability foundation (tie-in)
**Physics MUST run on a fixed timestep with an accumulator** ("Fix Your Timestep!", Gaffer On Games). Variable `dt` makes collision/physics non-deterministic and unstable (tunneling worsens, springs explode, replays/netcode desync).
```js
const STEP = 1/60; let acc = 0;
function frame(dt){ acc += Math.min(dt, 0.25); // clamp to avoid spiral-of-death after tab-out
while (acc >= STEP){ physicsStep(STEP); acc -= STEP; }
const alpha = acc / STEP; render(alpha); // interpolate between last two states
}
```
- **Clamp accumulated dt** (e.g. ≤0.25s) so a backgrounded tab doesn't trigger a "spiral of death" of catch-up steps.
- **Interpolate rendering** between the previous and current physics state using `alpha` for smooth visuals at any display rate.
- All physics engines expect a fixed step — call `world.step()` at a fixed rate, not per rAF with variable dt. Rapier is deterministic given fixed steps + same inputs (good for netcode).
---
## 7. 3D character controllers
Falling capsules from raw rigid-body dynamics feel bad for players — use a **kinematic character controller**:
- **Rapier `KinematicCharacterController`** (favored): create via `world.createCharacterController(offset)`, use a kinematic body + capsule/collider, then each fixed step call `computeColliderMovement(collider, desiredTranslation)` and read `computedMovement()` to get the collision-corrected move. Built-in **autostep** (stairs), **snap-to-ground** (don't float off slopes/ramps), **max slope climb angle**, and slide-along-walls. Set gravity/jump yourself (kinematic = you control motion).
- **cannon-es**: `PointerLockControlsCannon` example / a sphere or capsule body; more manual.
- Rules: use a **capsule** (not a box) so the player slides over small steps and around corners; apply movement each **fixed step**; do ground checks via the controller's grounded flag or a short downward ray; keep the visual mesh synced to the body each frame (with interpolation).
---
## 8. Bug-prevention checklist
- **Variable timestep for physics** → non-deterministic, jitter, worse tunneling, netcode desync; fixed step + accumulator.
- **No dt clamp** → "spiral of death" after tab-out; clamp accumulated dt.
- **Fast objects passing through walls** → tunneling; use swept AABB / CCD / raycast / substeps.
- **SAT on concave shapes** → wrong results; decompose into convex pieces.
- **AABB test on rotated boxes** → false/missed hits; use SAT for rotation.
- **O(n²) pair testing** → frame drops at scale; add spatial hash/quadtree broadphase.
- **Resolving both axes at once** → catching on tile seams / clipping corners; resolve X then Y.
- **Not zeroing velocity on the resolved axis** → gravity accumulates, object shudders into ground.
- **Player floats off ramps / trips on stairs** → missing snap-to-ground / autostep; use a character controller (Rapier) with those enabled.
- **Box character controller** → snags on edges; use a capsule.
- **`sqrt` in hot distance checks** → wasted perf; compare squared distances.
- **Reading physics state without render interpolation** → visible stutter at high refresh rates; interpolate with `alpha`.
---
## Defaults to apply
- **Right-size the solution:** hand-rolled **AABB + separate-axis resolution + swept collision** for arcade/2D/platformers; **Rapier** (2D or 3D) when we need real rigid bodies, joints, or a character controller. Don't default to a heavy engine.
- **Always run physics on a fixed timestep with a clamped accumulator + render interpolation** — this one pattern prevents tunneling instability and jitter, and it ties directly into the fixed-timestep game loop (see `threejs-foundational.md`).
- **Ship anti-tunneling by default** for fast objects (swept AABB or CCD/raycast for projectiles) and a **broadphase (spatial hash or quadtree)** once there are many colliders.
- **For 3D player movement, generate a Rapier `KinematicCharacterController` with a capsule, autostep, snap-to-ground, and slope limits** — not a raw dynamic body — so movement feels right out of the box.
---
## Sources
- MDN — 2D collision detection (AABB, circle, SAT): https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
- Gaffer On Games — "Fix Your Timestep!": https://gafferongames.com/post/fix_your_timestep/ ; "Collision Response and Coulomb Friction": https://gafferongames.com/post/collision_response_and_coulomb_friction/
- Swept AABB collision — jitter physics / "Swept AABB Collision Detection and Response" (gamedev): https://www.gamedev.net/tutorials/programming/general-and-gameplay-programming/swept-aabb-collision-detection-and-response-r3084/
- SAT reference — Metanet/N tutorial & dyn4j "SAT (Separating Axis Theorem)": https://dyn4j.org/2010/01/sat/ ; https://www.metanetsoftware.com/technique/tutorialA.html
- Spatial hashing / broadphase — "Spatial Hashing" (gamedev) & quadtree collision (GameDev Academy): https://gameprogrammingpatterns.com/spatial-partition.html
- Rapier — docs (rigid bodies, CCD, character controller): https://rapier.rs/docs/ ; character controller: https://rapier.rs/docs/user_guides/javascript/character_controller ; JS bindings: https://github.com/dimforge/rapier.js
- cannon-es — repo/examples (incl. PointerLockControlsCannon): https://github.com/pmndrs/cannon-es ; https://pmndrs.github.io/cannon-es/
- Matter.js: https://brm.io/matter-js/ ; Planck.js (Box2D): https://github.com/piqnt/planck.js
- Box2D docs (continuous collision / solver background): https://box2d.org/documentation/
- Game Programming Patterns — Spatial Partition & Game Loop: https://gameprogrammingpatterns.com/spatial-partition.html , https://gameprogrammingpatterns.com/game-loop.html
@@ -0,0 +1,111 @@
# ECS & Game State Architecture for JS Games (bitECS, miniplex, ECS vs OOP, state structure)
Consolidated from webgamedev.com, the bitECS and miniplex docs, and data-oriented-design canon (see Sources). Focus: how an AI builder should **structure game state so it stays fast and untangled** as a game grows — and when *not* to reach for ECS.
---
## 1. What ECS is (and why it exists)
**Entity-Component-System** separates data from behavior:
- **Entity** — an ID (or a plain object) with no logic; just a thing that exists.
- **Component** — pure **data**, no methods (Position, Velocity, Health, Sprite, Collider).
- **System** — pure **behavior** that iterates all entities having a given set of components and updates them (MovementSystem reads Position+Velocity).
Benefits: **composition over inheritance** (add/remove capabilities by adding/removing components — no deep class hierarchies), cache-friendly iteration, easy serialization (state = plain data), and clean separation that scales to thousands of entities.
The classic problem ECS solves: the "deadly diamond" of OOP inheritance (`FlyingEnemy` vs `SwimmingEnemy` vs `FlyingSwimmingShootingEnemy`...). With ECS you just attach `Flying`, `Swimming`, `Shooting` components in any combination.
---
## 2. When ECS vs OOP (don't cargo-cult ECS)
**Use plain OOP / a simple object list when:**
- Small games, jams, prototypes, or a handful of distinct entity types.
- Entity count is low (dozenslow hundreds) and perf isn't a concern.
- The team/model iterates faster with intuitive `player.jump()` classes.
- The engine already gives you a good model (Phaser GameObjects/prefabs, Three.js `Object3D` scene graph) — you can go a long way with a component-ish mixin approach without a full ECS.
**Reach for ECS when:**
- **Many similar entities** (bullet-hell, RTS, particles, simulations, .io games) — thousands of entities needing fast batch updates.
- Highly **combinatorial** entity capabilities (lots of mix-and-match behaviors/status effects).
- You need **determinism/serialization** (netcode, replays, save states) — pure-data components serialize trivially.
**Rule:** ECS is an optimization/organization tool, not a moral requirement. A giant `update()` switch or a tidy class hierarchy is fine for small games. Adopt ECS when entity count/combinatorial complexity actually bites — premature ECS adds boilerplate for no gain.
---
## 3. Library choice: bitECS vs miniplex
**miniplex** — DX-first, entities are **plain JS objects**, components are just properties. Best default for most indie/web games and rapid prototyping; excellent TypeScript + React bindings.
```js
import { World } from 'miniplex';
const world = new World();
const player = world.add({ position:{x:0,y:0}, velocity:{x:100,y:0}, health:{cur:100,max:100} });
const moving = world.with('position','velocity'); // live archetype query
function movementSystem(dt){ for (const e of moving){ e.position.x += e.velocity.x*dt; e.position.y += e.velocity.y*dt; } }
world.remove(player);
```
- Use `world.addComponent(e,'velocity',{...})` / `removeComponent` so queries re-index (mutating a bare property can skip re-indexing).
- `for...of` over a query is fast and **safe for removal during iteration**. No built-in scheduler — you call systems from your loop.
- Object property access is slightly slower at extreme entity counts than bitECS's typed arrays.
**bitECS** — performance-first, data-oriented: entities are **integer IDs**, components are **Structure-of-Arrays (typed arrays)**, archetype/bitmask queries. Best for tens of thousands of entities, WebGPU, perf-critical sims. ~5kb, zero deps (used in Hubs/Third Room; eyed by Phaser 4).
```js
import { createWorld, addEntity, addComponent, query } from 'bitecs';
const world = createWorld({ Position:{x:new Float32Array(1e4),y:new Float32Array(1e4)}, Velocity:{x:[],y:[]} });
const { Position, Velocity } = world.components;
const eid = addEntity(world); addComponent(world,eid,Position); addComponent(world,eid,Velocity);
const moving = query(world,[Position,Velocity]);
for (const e of moving){ Position.x[e] += Velocity.x[e]*dt; Position.y[e] += Velocity.y[e]*dt; }
```
- **Note the API moved on:** older tutorials use `defineComponent`/`defineQuery`/`Types`; current bitECS (0.4+) uses `createWorld({...schemas})` + `query(world,[...])`, plus relationships/observers/prefabs. Verify the version you install.
- SoA layout = cache-friendly; entities-as-IDs = trivial to serialize/network.
**Others:** Becsy (multithreaded), Koota, or the engine's own model. Some projects mix (bitECS core sim + object layer for UI).
---
## 4. Structuring game state (even without a formal ECS)
- **Single source of truth:** keep a central, serializable game-state object (or ECS world). Avoid scattering authoritative data across DOM, closures, and random globals — it makes save/load, undo, and netcode nearly impossible.
- **Separate data from rendering:** gameplay state (positions, health) should be independent of the visual objects (meshes/sprites). Systems update data; a render/sync step pushes data → Three.js/Phaser objects each frame. This keeps the sim testable/deterministic and lets you do fixed-timestep + interpolation.
- **Systems run in a defined order each tick:** input → AI → movement → collision → damage → cleanup → render-sync. Order matters and should be explicit, not incidental.
- **Object pooling instead of create/destroy churn:** for bullets/enemies/particles, recycle entities (mark inactive) rather than allocating/GCing every frame (pairs with ECS well; see `threejs-foundational.md`).
- **Deferred structural changes:** don't add/remove entities in the middle of iterating a query in ways the lib doesn't support — queue spawns/despawns and apply between systems (miniplex `for...of` is removal-safe; still queue mass changes for clarity).
- **Fixed timestep for the sim** (see game-loop/collision skills) so ECS systems are deterministic — required for netcode/replays.
- **Events/messaging** between systems via a small event queue rather than direct cross-system calls, to keep systems decoupled.
---
## 5. Bug-prevention checklist
- **Putting logic in components / data in systems** → defeats ECS; keep components pure data, systems pure behavior.
- **Adopting ECS for a tiny game** → boilerplate with no benefit; use a simple object list/classes until entity count/complexity warrants it.
- **Deep inheritance for entity variety** → combinatorial class explosion; compose with components instead.
- **Mutating a miniplex property directly and expecting query updates** → use `addComponent`/`removeComponent` so archetypes re-index.
- **Using stale bitECS `defineComponent` tutorials against a new version** → API mismatch; check installed version's docs.
- **Mixing render objects into authoritative state** → non-serializable, non-deterministic; separate sim data from view.
- **Undefined system order** → order-dependent bugs (moving before collision, etc.); define an explicit pipeline.
- **create/destroy per frame** → GC stalls; pool entities.
- **Structural changes mid-iteration** → skipped/duplicated updates; defer to a queue.
- **Scattered global state** → save/load and netcode become impossible; centralize the world.
---
## Defaults to apply
- **Default to miniplex** for generated games that need ECS (plain objects, great TS/React DX); **switch to bitECS** only when the game genuinely has thousands of entities or needs SoA perf. For small/simple games, a plain object list or engine prefabs is fine — don't force ECS.
- **Always structure state as a single serializable world with sim/render separation** and an **explicit system pipeline** (input→AI→movement→collision→damage→cleanup→render-sync) run on a fixed timestep. This unlocks save/load, replays, and netcode for free.
- **Bake in pooling + deferred spawn/despawn** for entity-heavy genres.
- When generating ECS code, **verify the library version's current API** (bitECS especially changed) rather than emitting old `defineComponent` patterns from memory.
---
## Sources
- Web Game Dev — Code architecture / ECS overview: https://www.webgamedev.com/code-architecture/ecs
- bitECS — repo & docs: https://github.com/NateTheGreatt/bitECS , https://bitecs.dev/docs/introduction
- miniplex — repo & docs: https://github.com/hmans/miniplex , miniplex-react: https://github.com/hmans/miniplex/tree/main/packages/miniplex-react
- Becsy (multithreaded ECS): https://lastolivegames.github.io/becsy/
- Sander Mertens — "Entity Component System FAQ": https://github.com/SanderMertens/ecs-faq
- Adam Martin — "Entity Systems are the future of MMOG development": http://t-machine.org/index.php/2007/09/03/entity-systems-are-the-future-of-mmog-development-part-1/
- Mick West — "Evolve Your Hierarchy" (components over inheritance): https://cowboyprogramming.com/2007/01/05/evolve-your-hierarchy/
- Richard Fabian — Data-Oriented Design (book): https://www.dataorienteddesign.com/dodbook/
- Game Programming Patterns — Component pattern: https://gameprogrammingpatterns.com/component.html
@@ -0,0 +1,110 @@
# Game Feel & Juice (screenshake, hitstop, tweening/easing, particles, squash & stretch, camera, SFX layering)
The canon: Steve Swink's *Game Feel*, Vlambeer/"Juice it or lose it" (Martin Jonasson & Petri Purho), and Jan Willem Nijman's "The art of screenshake" (see Sources). "Juice" = generous, layered, non-gameplay-affecting feedback that makes every input feel physical and satisfying. Focus: what an AI builder should reflexively add so games feel **alive instead of static**, without hurting readability or perf.
---
## 1. The core principle
**Every meaningful action should produce disproportionate, multi-sensory feedback.** A single "hit" should simultaneously trigger several of: sound, particles, screenshake, hitstop, flash, knockback, squash/stretch, and a number/pop. The gameplay math can stay simple — the *feel* comes from the feedback layer stacked on top. The classic demo takes the same identical Breakout and makes it feel amazing purely by adding these layers.
**Rule:** Separate "simulation" from "presentation." Feedback (shake, tween, particles, flash) must **never change gameplay outcomes** — it's cosmetic. This keeps physics deterministic (important for netcode/replays) while letting you pile on juice freely.
---
## 2. Screenshake (the highest impact-per-effort effect)
- Offset the **camera** (or a camera container), not the world objects. Apply a random offset each frame from a **trauma value** that **decays over time**.
- **Use trauma², not linear** (Squirrel Eiserloh's "Juicing Your Cameras"): store `trauma` in [0,1], set `shake = trauma * trauma` (or `trauma³`). Add trauma on events (small hit +0.3, explosion +0.8), decay `trauma -= decay * dt` each frame. Squaring makes small hits subtle and big hits explosive.
- Offset with **noise, not pure random**, for smoother shake: `offsetX = maxOffset * shake * (noise(t) )`; also shake **rotation** slightly (`maxAngle * shake * noise`) — rotational shake reads as more violent.
- **Directional shake** for directional hits (kick the camera opposite the impact) reads better than omnidirectional for melee/recoil.
- **Cap it & scale it down for accessibility.** Excessive shake causes motion sickness — expose a "screen shake" slider/toggle. Never shake so hard UI/targets become unreadable.
- Scale offsets by `dt`-independent amount but decay by `dt` so it's frame-rate independent.
Nijman's rules also include: **more bullets, bigger bullets, muzzle flash, impact effects, permanent decals, camera lerp/lookahead, camera kick, sleep/hitstop, gun delay, knockback, screen shake, and even a little randomness in everything.**
---
## 3. Hitstop / hitstun / "sleep" (freeze frames on impact)
- On a big hit, **freeze the game (or just the two involved entities) for a few frames** (~30120ms), then resume. This sells impact enormously — the brain reads the pause as force.
- Implement as a global **time scale** or a short "freeze frames" counter: while frozen, skip the gameplay update (or set `timeScale=0`) but **keep rendering** and keep the flash/particles visible. Common: freeze ~26 frames for normal hits, longer for finishers.
- Keep hitstop **short**; too long feels laggy/unresponsive. Often paired with a brief hit **flash** (tint the sprite white for 12 frames) and knockback that starts *after* the freeze.
- Distinguish **hitstop** (brief freeze for feel) from **hitstun** (gameplay state where a hit character can't act) — the latter is real gameplay, the former is pure juice.
---
## 4. Tweening & easing (motion that isn't linear)
- **Almost nothing should move linearly.** Real, satisfying motion accelerates/decelerates. Use **easing functions** for UI transitions, pickups flying to the HUD, menus, popups, camera moves, damage numbers.
- Common curves (see easings.net for exact formulas/graphs):
- **easeOutQuad/Cubic** — arrive gently (great for things settling into place).
- **easeInQuad/Cubic** — start slow (things launching).
- **easeOutBack** — slight overshoot then settle (pop-in for UI/pickups — very juicy).
- **easeOutElastic / easeOutBounce** — springy/bouncy (buttons, coin pickups). Use sparingly.
- **easeInOutQuad** — smooth both ends (camera, generic).
- **Use a tween library** rather than hand-rolling: **GSAP** (best-in-class, huge easing set, timelines), **@tweenjs/tween.js** (lightweight, common in Three.js), or the engine's built-in (Phaser `this.tweens.add`). Tweens compose (chains, delays, yoyo, repeat) and auto-handle dt.
- **Kill/clean up tweens** when the target is destroyed or the scene shuts down (leaked tweens mutating dead objects = crashes/bugs). Phaser: kill on `SHUTDOWN`; GSAP: `.kill()`.
- A **spring/damped-lerp** (`x += (target - x) * (1 - exp(-k*dt))`) is a great procedural alternative for continuous following (cameras, cursors, UI) — always frame-rate correct if you use the `exp` form rather than `x += (target-x)*0.1` (the naive lerp is frame-rate dependent — see §6).
---
## 5. Particles & squash-and-stretch
- **Particles everywhere:** dust on landing/footsteps, sparks on hit, debris on death, trails on projectiles, confetti on win, ambient motes. They cost little and add enormous life. Use the engine's particle system (Phaser `add.particles`, Three.js points/instanced sprites) and **pool** them (no per-emit allocation — see `threejs-foundational.md`).
- **Squash & stretch** (Disney's foundational animation principle): deform on acceleration to convey weight/speed while preserving apparent volume.
- Jump: stretch vertically (taller/thinner) on takeoff, squash (shorter/wider) on landing, then spring back via `easeOutBack`/elastic.
- Preserve volume: if you scale `y` by `s`, scale `x` by ~`1/s`, so it doesn't look like it's growing.
- Drive it from velocity (stretch along the movement direction) or trigger on discrete events (land, hit, shoot recoil).
- **Anticipation + follow-through:** wind up before a big action (tiny reverse/squash) and overshoot after — even a couple frames sells it.
- **Pop numbers & flashes:** floating damage numbers that rise + fade + scale-pop, combo counters, "+10" pickups. Cheap, huge readability + satisfaction win.
---
## 6. Camera feel (lerp, lookahead, deadzone, punch)
- **Never hard-snap the camera to the player.** Smoothly follow (`lerp`/spring) toward the target so motion is fluid.
- **Frame-rate-correct smoothing:** `pos += (target - pos) * (1 - Math.exp(-k * dt))`. The naive `pos += (target - pos) * 0.1` is **frame-rate dependent** (faster on 144Hz than 60Hz) — a common bug; use the exp form or a fixed-timestep camera update.
- **Lookahead:** offset the camera slightly in the direction of movement / where the player is aiming, so the player sees more of what's ahead. Ease the offset in/out.
- **Deadzone / soft zone:** don't move the camera until the player leaves a central box — prevents nauseating micro-jitter from small movements.
- **Camera punch/kick** on impacts (a quick zoom-in or positional kick that eases back) complements screenshake.
- Clamp the camera to level bounds; round the final camera position to whole pixels for pixel-art games to avoid shimmer.
---
## 7. SFX layering & audio feel
- **Layer sounds** for richness: a single "shoot" = a low thump + a mid body + a high click/transient. Impacts = hit + debris + a tail.
- **Pitch-randomize** repeated sounds (±515%) so rapid fire / footsteps don't sound robotic (`playbackRate` / Howler `rate`). Slightly randomize volume too.
- Sound is feedback: **every** action should have audio, and it should be tight (low latency) and satisfy on first frame of the action (see the audio skill for Web Audio/Howler, mobile unlock, buses).
- Duck music briefly under big events; add a subtle low-frequency "boom" on explosions.
---
## 8. Don't over-juice (readability & perf guardrails)
- **Juice must not obscure gameplay.** If shake/flash/particles hide the player, enemies, or projectiles, dial back. Readability > spectacle.
- **Accessibility:** provide toggles/sliders for screen shake, flashing, and reduced motion (respect `prefers-reduced-motion`). Heavy flashing risks photosensitivity — avoid rapid full-screen strobing.
- **Perf:** pool particles/tweens, cap concurrent particles, don't allocate in the hit path. Juice is cheap but "thousands of unpooled particles per explosion" is not.
- Keep hitstop short and prediction-safe in multiplayer (apply juice on the client only; never let it alter the authoritative sim).
---
## Defaults to apply
- **Auto-juice defaults:** whenever the builder generates a hit/pickup/death/land/win event, reflexively attach the stack: **sound + particles + short hitstop + white flash + screenshake(trauma²) + easeOutBack pop / squash-stretch + floating number.** Same simple gameplay, dramatically better feel — this is the single biggest perceived-quality lever.
- **Ship a tiny reusable "juice" toolkit** in generated games: a `trauma`-based shake (squared, noise-driven, decaying), a `hitstop(frames)` helper (timeScale/freeze-count), a `flash(sprite)` helper, easing/tween helpers (or GSAP/tween.js), and frame-rate-correct camera follow with lerp + lookahead + deadzone.
- **Always use the `exp`-based lerp** for cameras/followers so smoothing is frame-rate independent — bake this in to avoid the classic 144Hz-vs-60Hz bug.
- **Separate presentation from simulation** so juice never affects gameplay/netcode. Provide screenshake/flash/reduced-motion toggles by default for accessibility.
---
## Sources
- Martin Jonasson & Petri Purho — "Juice it or lose it" (GDC talk): https://www.youtube.com/watch?v=Fy0aCDmgnxg ; companion "Game feel" write-up on grapefrukt.
- Jan Willem Nijman (Vlambeer) — "The art of screenshake": https://www.youtube.com/watch?v=AJdEqssNZ-U
- Steve Swink — *Game Feel: A Game Designer's Guide to Virtual Sensation* (book), gamefeelbook.com.
- Squirrel Eiserloh — "Juicing Your Cameras With Math" (GDC, trauma² screenshake): https://www.youtube.com/watch?v=tu-Qe66AvtY
- easings.net — easing function reference with formulas & graphs: https://easings.net/
- Robert Penner's easing equations (origin of the standard easings): http://robertpenner.com/easing/
- GSAP docs (tweening/easing/timelines): https://gsap.com/docs/v3/ ; tween.js: https://github.com/tweenjs/tween.js
- Disney's 12 principles of animation (squash & stretch, anticipation, follow-through): https://en.wikipedia.org/wiki/Twelve_basic_principles_of_animation
- MDN — `prefers-reduced-motion`: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
- "The Juice Factor" / GDC Vault talks on feedback & game feel.
@@ -0,0 +1,89 @@
# Genre Playbook — Board & Card Games (Chess, Checkers, Tic-Tac-Toe, Card games)
Turn-based logic games where correctness *is* the game: an illegal move or a flaky turn transition breaks trust instantly. The genre is about a clean **turn state machine**, **rigorous legal-move validation**, and (for solo play) a **minimax / alpha-beta AI**. Almost always 2D (Canvas/DOM/Phaser); rendering is trivial, logic is everything. Read `../threejs-foundational.md` for general structure; this file is about game-logic correctness.
---
## 1. Core mechanics (minimal-but-good scope for a demo)
1. **A board/state data model** separate from the view (array of pieces / card lists).
2. **A turn state machine** that governs whose turn it is and what's allowed.
3. **Legal-move generation + validation** (only legal moves accepted and highlighted).
4. **Win/draw/loss detection** (checkmate/stalemate, three-in-a-row, deck empty, etc.).
5. **A simple AI opponent** (minimax/alpha-beta for perfect-info games; heuristics for card games).
For chess specifically: **do not hand-roll the rules for a demo unless asked — use `chess.js`** for move generation/validation/checkmate and `chessground`/a board lib for the UI. Hand-rolling chess rules (en passant, castling through check, pins, promotion, threefold repetition) is a huge bug surface. Build custom rules only for simpler games (tic-tac-toe, checkers, connect-4) or when explicitly required.
---
## 2. Turn state machine
Model the game as an explicit finite state machine — never as ad-hoc booleans scattered across handlers.
- **States (typical):** `PlayerTurn (idle → pieceSelected → moveConfirmed)``Resolving/Animating``CheckWinCondition``OpponentTurn` → … → `GameOver`.
- **One source of truth for `currentPlayer`.** Only accept input during that player's `idle`/`selecting` state; **ignore all input while animating or during the AI's turn** (a top bug: clicking during resolution corrupts state or lets a player move twice).
- **Strict transitions:** select piece → show legal moves → choose destination → validate → apply move → switch player → check terminal conditions. Each step gated; no shortcuts.
- **Undo/history:** keep a move stack (and/or full state snapshots) — enables undo, threefold-repetition detection, and replay. Store enough to reverse a move (captured piece, castling/en-passant flags).
- **Determinism:** the model must be pure and reproducible; the view only reflects it. This makes AI, undo, and (later) networking possible.
---
## 3. Legal-move validation (get this exactly right)
- **Generate, then filter.** Produce candidate moves per piece, then remove illegal ones. **Only offer/accept legal moves** — highlight them; reject clicks on illegal squares.
- **Chess "leaves your king in check" rule (the classic omission):** a move is illegal if, *after* making it, your own king is attacked. Implement by making the move on a copy, checking if your king is attacked, and rejecting if so. This automatically handles pins — don't try to special-case pins.
- **Special chess rules to not forget:** castling (king & rook unmoved, squares empty, king not in/through/into check), en passant (only immediately after the enemy pawn's two-square move), pawn promotion, and the 50-move / threefold-repetition / stalemate draws. These are exactly why `chess.js` exists.
- **Terminal detection:** checkmate = in check AND no legal moves; stalemate = not in check AND no legal moves (a draw, not a loss — a very common bug is scoring stalemate as a win). For tic-tac-toe/connect-4, check all lines after each move and also detect a full-board draw.
- **Card games:** validate legality against the rules (can you play this card now?), keep hidden information hidden (trivial in single-player; true hidden-info enforcement needs a server, which is out of scope on this deploy target), and shuffle with an unbiased algorithm (**FisherYates**, not `sort(() => Math.random()-0.5)` which is biased).
---
## 4. AI: minimax & alpha-beta pruning
For **perfect-information, deterministic** games (chess, checkers, tic-tac-toe, connect-4, Othello):
- **Minimax:** recursively explore the game tree; the mover **maximizes** their evaluation, the opponent **minimizes** it. At the depth limit (or terminal node), return a heuristic evaluation. Choose the move leading to the best guaranteed outcome.
- **Alpha-beta pruning:** carry `alpha` (best already guaranteed to the maximizer) and `beta` (best guaranteed to the minimizer); **prune** a branch when `alpha >= beta` (the opponent would never allow it). Same result as minimax, far fewer nodes → much deeper search. Move ordering (try likely-good moves first, e.g. captures) dramatically improves pruning.
- **Depth = difficulty.** Tic-tac-toe: search to the end (perfect play). Connect-4/checkers: a few plies. Chess: alpha-beta + a material+position evaluation gets a decent club-level bot; go deeper/iterative-deepening for stronger.
- **Evaluation function (heuristic):** for chess, material values (P=1, N/B=3, R=5, Q=9) + piece-square tables (positional bonuses) + mobility/king safety. Terminal nodes return ±∞ for win/loss, 0 for draw. A good eval matters more than raw depth for feel.
- **Avoid freezing the UI:** run deeper searches so they don't block the main thread — use a **Web Worker**, iterative deepening with a time budget, or `requestAnimationFrame`-chunked search. A 2-second UI hang while the AI "thinks" feels broken.
- **Imperfect-info / non-deterministic games** (most card games) need different AI: rules-based heuristics, Monte Carlo (MCTS) with determinization, or expectiminimax. Don't shoehorn plain minimax into a hidden-hand card game.
---
## 5. Common bugs to avoid (checklist)
- **Accepting input during animation or the AI's turn** → double moves / corrupted state. Gate all input on the turn state.
- **Offering illegal moves** → validate + highlight only legal moves; reject the rest.
- **(Chess) Allowing a move that leaves your own king in check** → test post-move king safety; this also handles pins.
- **(Chess) Missing en passant / castling-through-check / promotion / draw rules** → use `chess.js` instead of hand-rolling.
- **Scoring stalemate as a win** → stalemate is a draw; checkmate = in check + no legal moves.
- **Biased shuffle** (`sort(Math.random)`) → use FisherYates.
- **State in the DOM/sprites, not a model** → makes AI, undo, and win-checks unreliable; keep a pure model.
- **AI blocks the main thread** → search in a Web Worker or time-boxed/iterative; keep the UI responsive.
- **Minimax min/max sign errors** → a classic; test against known positions (mate-in-1, forced draws).
- **No move history** → can't undo or detect repetition; keep a reversible move stack.
---
## Defaults to apply
1. **For chess, default to `chess.js` (rules/validation/mate) + a board UI lib** rather than hand-rolling. Hand-roll only simple games (tic-tac-toe, connect-4, checkers) or when asked.
2. **Model the game as an explicit turn state machine with one `currentPlayer` source of truth; ignore input during animation/AI turns.** Keep a pure model separate from the view + a reversible move history.
3. **Only ever present and accept LEGAL moves** (generate → filter → highlight). For chess, reject any move that leaves your own king in check.
4. **Detect terminal states correctly** — checkmate vs stalemate (draw!), draws, and full-board ties.
5. **AI = minimax + alpha-beta pruning with a heuristic eval; depth = difficulty; run in a Web Worker / time-boxed so the UI never freezes.** Use MCTS/heuristics (not plain minimax) for hidden-info card games; shuffle with FisherYates.
6. **Minimal scope:** correct rules + turn FSM + legal-move UI + win/draw detection + one AI opponent. Correctness beats features here.
---
## Sources
- chess.js — move generation, validation, check/checkmate/draw detection: https://github.com/jhlywa/chess.js
- chessground — Lichess's board UI: https://github.com/lichess-org/chessground
- Chess Programming Wiki — Minimax: https://www.chessprogramming.org/Minimax
- Chess Programming Wiki — Alpha-Beta: https://www.chessprogramming.org/Alpha-Beta
- Chess Programming Wiki — Evaluation & Piece-Square Tables: https://www.chessprogramming.org/Evaluation and https://www.chessprogramming.org/Piece-Square_Tables
- Red Blob Games — game trees / minimax intuition and grid tools: https://www.redblobgames.com/
- Wikipedia — FisherYates shuffle (unbiased): https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
- Wikipedia — Monte Carlo tree search (imperfect-info/large trees): https://en.wikipedia.org/wiki/Monte_Carlo_tree_search
- MDN — Web Workers (off-thread AI search): https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
@@ -0,0 +1,83 @@
# Genre Playbook — Endless Runner
Auto-forward games where the player dodges obstacles and collects pickups at ever-increasing speed (Temple Run, Subway Surfers, Canabalt, Flappy-adjacent, Chrome Dino). Works in 2D (Phaser) or 3D (Three.js). The whole genre rests on **procedural spawning + object pooling + a difficulty ramp**. Read `../threejs-foundational.md` first (delta time, pooling, mobile input).
---
## 1. Core mechanics (minimal-but-good scope for a demo)
1. **Auto-run:** the player moves forward automatically (or the world scrolls toward a fixed player) at a speed that **increases over time**.
2. **Simple avoidance input:** jump / slide, or lane-switch left/right (or both).
3. **Endless procedural spawning** of obstacles + collectibles as pooled objects.
4. **Collision → death** (one hit or a few lives), with **distance/coins as score**.
5. **Instant restart** and a persisted high score.
One lane setup + jump/slide + one obstacle type + coins + a speed ramp + hi-score is a complete, replayable game. Add power-ups, multiple biomes, and characters later. **The tuning of the speed/difficulty curve is the game.**
---
## 2. The "endless" trick: scroll the world, recycle the pieces
- **Keep the player near a fixed position; move the world toward them** (or move the player and follow with the camera — mathematically equivalent). Fixed-player + moving-world is usually simpler for spawning and scoring by distance.
- **Chunk/segment spawning:** build the track from prefab segments (ground tiles, obstacle patterns). Spawn a new segment ahead when the last one enters view; **despawn/recycle** segments once they pass behind the player/camera.
- **Object pooling is mandatory.** Runners spawn thousands of obstacles/coins over a run — preallocate pools and recycle (`active/visible=false` in Phaser, or a free-list). Never create/destroy per spawn (GC stutter kills the feel).
- **Parallax background** (2D): multiple layers scrolling at different speeds for depth; use `tileSprite`/texture offset scrolling rather than spawning background sprites.
- **Seamless ground:** loop a tiling ground texture by scrolling UV/tilePosition, or ping-pong two ground pieces, so there's no visible seam.
---
## 3. Controls & feel
- **Jump:** use platformer forgiveness where relevant — **jump buffering** (register a press slightly before landing) and a short **coyote window** make it feel fair. Variable jump height optional. Clamp fall speed.
- **Slide/duck:** a timed crouch that shrinks the hitbox; must end even if the key is held-then-released oddly (timer-driven, not just key-held).
- **Lane switch (3D runners):** discrete lanes; **tween** the player smoothly between lane X positions rather than teleporting, and lock input during the transition or allow queueing. Support swipe (mobile) + arrows/A-D (desktop).
- **One-button variants (Flappy/Canabalt):** tap = flap/jump; keep the single input crisp and buffered.
- **Coyote/buffer + snappy, immediate response** — runners punish input lag hard because timing is everything.
- **Fairness rule:** never spawn an **impossible/unavoidable** obstacle combination for the current speed. Constrain the spawner so the player always has a reachable gap/lane (see §5).
---
## 4. Difficulty & scoring
- **Speed ramps up with distance/time** (linear or gently curved), which naturally raises difficulty. Tie obstacle density and pattern complexity to the same progression.
- **Cap the max speed** or the game becomes unplayable / physics/collision get unstable — and above some speed the player literally can't react.
- **Reaction-time budget:** obstacles must appear far enough ahead that the player has time to react at the current speed (spawn distance should scale with speed). This is the core fairness constraint.
- **Score = distance** (+ coins). Persist the high score in `localStorage`. Show near-misses / combos for extra juice.
- **Pickups:** coins in patterns (arcs, lines) that sometimes lure the player into risk; occasional power-ups (magnet, shield, jetpack) on a timer.
---
## 5. Common bugs to avoid (checklist)
- **Creating/destroying objects per spawn** → GC hitches; use pools and recycle off-screen objects.
- **Impossible obstacle combos** → spawner must always leave a reachable gap/lane for the current speed; validate patterns.
- **Obstacles appear too late to dodge** → spawn distance / reaction budget must scale with speed.
- **Unbounded speed** → cap max speed; otherwise collision tunneling and unfair, unplayable pace.
- **Collision tunneling at high speed** → swept/segment collision or sub-stepping, not per-frame overlap only.
- **Visible seams in ground/background** → scroll UV/tilePosition or ping-pong two pieces; don't spawn gap-prone tiles.
- **Objects never despawn** → recycle everything behind the camera; otherwise memory/entity count grows until it crashes.
- **Movement/scroll not delta-scaled** → speed differs per frame rate.
- **Slide/jump state gets stuck** → drive crouch/jump with timers and reset on death/restart.
- **No fair restart** → reset speed, pools, score, and player state fully on restart (leftover pooled objects reappearing is a classic bug).
---
## Defaults to apply
1. **Fixed player + scrolling/recycled world built from pooled prefab segments** is the canonical, bug-resistant structure. Pool obstacles, coins, and segments; recycle behind the camera.
2. **Difficulty = increasing speed tied to distance, with a hard speed cap.** Scale obstacle spawn distance to the current speed so there's always time to react.
3. **Never spawn unavoidable obstacles** — the spawner must guarantee a reachable gap/lane for the current speed.
4. **Snappy input with jump buffering + coyote time**; tween lane switches smoothly; support swipe + keyboard.
5. **Score by distance + coins, persisted in localStorage; instant full-reset restart.**
6. **Use swept collision + a speed cap** to avoid high-speed tunneling. Add parallax + seamless scrolling ground for polish.
---
## Sources
- MDN — 2D collision detection & game techniques: https://developer.mozilla.org/en-US/docs/Games/Techniques
- Phaser — Groups / object pooling (recycle sprites): https://docs.phaser.io/phaser/concepts/gameobjects/group
- Phaser — TileSprite (scrolling ground/parallax): https://docs.phaser.io/api-documentation/class/gameobjects-tilesprite
- GMTK / general platformer feel (jump buffering, coyote time apply to runners too): https://gmtk.itch.io/platformer-toolkit
- "How Canabalt / procedural runner level generation works" (chunk-based spawning): https://www.gamedeveloper.com/design/the-power-of-procedural-generation-in-canabalt
- Chrome Dino (T-Rex Runner) open-source reference: https://github.com/wayou/t-rex-runner
- Gaffer On Games — Fix Your Timestep (high-speed collision stability): https://gafferongames.com/post/fix_your_timestep/
@@ -0,0 +1,105 @@
# Genre Playbook — First-Person Shooter (FPS)
How to build a browser FPS that *feels* right (fast, snappy, weighty) and avoids the classic bugs. Assumes Three.js/Babylon.js. Read `../threejs-foundational.md` first — this file only adds FPS-specific rules (it already covers pointer lock + WASD basics, delta time, disposal, draw calls).
---
## 1. Core mechanics (minimal-but-good scope for a demo)
A convincing FPS demo needs, in priority order:
1. **Mouse-look + WASD movement** with pointer lock (yaw/pitch, clamped pitch).
2. **One weapon that shoots** with a crosshair, muzzle flash, and hit feedback.
3. **Something to shoot** — 38 targets or simple enemies with a hit reaction + death.
4. **A hit indicator + score/ammo HUD.** Feedback is the game.
5. **A bounded arena** with walls/cover and collision so you can't walk through geometry.
Do NOT try to ship AI pathfinding, multiple weapons with full inventory UI, reload animations, or netcode in a first demo. A single hitscan weapon + stationary/patrolling targets + juice (screen shake, flash, sound) reads as a real game.
---
## 2. Controls & camera
- **Pointer Lock** is mandatory (see MDN below). Request it from a **user gesture** (`canvas.addEventListener('click', () => canvas.requestPointerLock())`), show a "Click to play" blocker, and re-show it on `pointerlockchange` when `document.pointerLockElement === null`. Prefer `requestPointerLock({ unadjustedMovement: true })` to disable OS mouse acceleration for consistent aim; **fall back gracefully** if the returned Promise rejects with `NotSupportedError`.
- **Read `movementX`/`movementY` on `mousemove`, not `clientX/Y`.** While locked, `clientX/Y` are frozen; only the deltas update. `yaw -= movementX * sensitivity; pitch -= movementY * sensitivity`.
- **Clamp pitch to just under ±90°** (`±89°`, i.e. `Math.PI/2 - 0.01`) so the camera never flips/gimbal-locks looking straight up/down.
- **Yaw the body (a container), pitch the camera.** Standard rig: a yaw `Object3D` holding the camera; apply yaw to the container, pitch to the camera. Movement (`moveForward/moveRight`) uses body yaw only — never pitch — so looking up doesn't make you fly.
- **Sensitivity + optional ADS zoom:** store a base sensitivity; when aiming-down-sights, lower FOV *and* scale sensitivity down proportionally so aim feels consistent.
- **Add ESC-to-unlock** and pause. Only re-attach the `mousemove` handler while locked; detach on unlock so a paused game doesn't rotate.
- **Movement feel:** acceleration + friction (not instant velocity), add sprint (Shift), crouch, and subtle **head-bob** driven by a sin wave of distance traveled (disable when idle). Optional: coyote-style step-up for small ledges. Air control should be reduced vs ground.
- **FOV:** 7590° vertical feels good for FPS; too low feels claustrophobic, too high distorts. Let the player adjust.
---
## 3. Weapon handling & the viewmodel
### ⚠️ Do NOT use a generated PHOTO as the weapon viewmodel.
The single most common failure mode: the builder generates an **opaque JPG/PNG photo of a gun** and slaps it as a flat sprite/plane in the bottom-right of a 3D scene. It looks *wrong* — no parallax, wrong perspective, a hard rectangular edge, lighting that doesn't match the world, and it z-fights or clips into walls. **The world is 3D; the gun must be 3D too.**
**Instead:**
- **Build the viewmodel from 3D geometry/code** — a few boxes/cylinders (`BoxGeometry`, `CylinderGeometry`) assembled into a low-poly gun, or a proper **glTF/GLB model** (right-handed, Y-up; verify +Z-forward orientation). Even a crude boxy gun made of primitives reads far better than a photo because it has real perspective and lighting.
- **Parent the viewmodel to the camera** (add it as a child of the camera, offset to e.g. `(0.3, -0.3, -0.6)`) so it tracks the view. Because the camera's local forward is **Z**, the gun sits at negative Z in front of the camera.
- **Render it so it never clips into walls.** Two robust options: (a) a **separate overlay scene + second camera** rendered after the main scene with `renderer.autoClear=false` and `renderer.clearDepth()` between passes, or (b) put the viewmodel on its own **layer** with a dedicated camera. This guarantees the gun draws on top and never intersects level geometry.
- If you must use a texture, it must be a **transparent PNG on correctly-perspectived geometry**, never a raw opaque photo on a screen-aligned quad.
### Shooting model
- **Hitscan (raycast) for fast bullets** (pistols/rifles): on fire, `raycaster.setFromCamera({x:0,y:0}, camera)` (center of screen = crosshair) and `intersectObjects(targets)`. Take the nearest hit; apply damage. Instant, cheap, and what most shooters use.
- **Projectiles for slow/arcing shots** (rockets, grenades): spawn a pooled mesh, move by velocity·delta, integrate gravity, and sweep-test for collisions (avoid tunneling — see bugs).
- **Fire rate:** gate with a cooldown timer (`if (now - lastShot < fireInterval) return`), not per-frame or per-mousedown-event alone. Support hold-to-fire for automatics via a boolean set on mousedown/up.
- **Recoil & spread:** kick the camera pitch/yaw up slightly per shot and recover over time (lerp back); add a small random cone to the ray direction that grows while firing continuously and shrinks when idle. This is 80% of "feel."
- **Ammo + reload:** track `magazine`/`reserve`; block firing at 0; a timed reload that refills. Even a fake reload with a timer + HUD reads well.
---
## 4. Genre-specific "feel" (the juice)
FPS feel is almost entirely feedback and responsiveness:
- **Muzzle flash** (a brief additive sprite/light at the barrel, 12 frames), **shell/particle**, and a **tracer** for projectiles.
- **Screen shake** on fire and on taking damage (small, decaying camera offset).
- **Hitmarker** (crosshair flashes / an X appears) + **hit sound** the instant a shot lands — the game must confirm every hit.
- **Enemy hit reaction**: flash the material, knockback, a death animation/ragdoll or a satisfying pop.
- **Weapon sway & bob**: viewmodel lags slightly behind fast mouse movement (lerp toward target offset) and bobs while walking.
- **Sound is half the feel**: distinct fire, impact, reload, footstep, and empty-click sounds; unlock the audio context on first gesture.
- **Snappy input**: never add input lag; process shooting on the event but resolve in the fixed/render step. Aim assist is optional on mobile/gamepad.
---
## 5. Common bugs to avoid (checklist)
- **Photo-as-viewmodel** (see §3) → use 3D geometry/model, render as an overlay layer.
- **Reading `clientX/Y` instead of `movementX/Y`** while locked → camera doesn't rotate.
- **Pitch not clamped** → camera flips upside down at the poles.
- **Applying pitch to movement** → you "fly" when looking up. Movement uses yaw only.
- **Firing tied to frame rate / no cooldown** → 144Hz machine empties the mag instantly.
- **Projectile tunneling** through thin walls at high speed → use raycast sweep between last and current position, or a fixed timestep + continuous collision, not just point-in-frame checks.
- **Raycasting from the mouse position instead of screen center** → shots miss the crosshair. Use `{x:0, y:0}` NDC (center).
- **Viewmodel clips into walls / z-fights** → separate overlay render pass or dedicated layer + `clearDepth()`.
- **Pointer lock never re-engages after ESC** → you didn't re-show the blocker / re-bind click on `pointerlockchange`.
- **Not disposing enemies/bullets** → memory leak; pool projectiles and enemies.
- **No collision** → walking through walls/floor. Add a capsule/AABB character collider (Rapier `KinematicCharacterController`).
- **Mobile:** pointer lock is unsupported on iOS/most mobile — provide touch look/joystick + fire button fallback and don't hard-require lock.
---
## Defaults to apply
Concrete rules to apply:
1. **NEVER render a weapon as a flat generated photo/JPG in a 3D FPS.** Build the viewmodel from Three.js primitives or load a glTF model, parent it to the camera, and render it as a separate overlay pass/layer so it never clips. A boxy code-built gun > a photorealistic sprite.
2. **Pointer lock is mandatory and gesture-gated:** click-to-play blocker, `requestPointerLock({ unadjustedMovement:true })` with fallback, read `movementX/Y`, clamp pitch to ±89°, re-show blocker on unlock.
3. **Separate yaw (body) from pitch (camera); movement uses yaw only.**
4. **Default to hitscan** with a raycast from screen center; gate fire with a cooldown timer; add recoil + spread + recovery. Projectiles only for slow/arcing weapons, with sweep tests to prevent tunneling.
5. **Ship juice, not features:** muzzle flash, hitmarker, hit sound, screen shake, enemy flash-on-hit. This makes a 1-weapon demo feel like a game.
6. **Minimal scope:** one weapon, a handful of targets/enemies, an arena with collision, and a HUD (ammo + score). Pool bullets/enemies; dispose on cleanup.
7. **Provide a mobile fallback** (touch look + fire button) since pointer lock is desktop-only.
---
## Sources
- MDN — Pointer Lock API: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API
- MDN — `Element.requestPointerLock()` (options, `unadjustedMovement`, Promise): https://developer.mozilla.org/en-US/docs/Web/API/Element/requestPointerLock
- MDN — pointer lock live demo: https://mdn.github.io/dom-examples/pointer-lock/
- Three.js — `PointerLockControls` example: https://threejs.org/examples/misc_controls_pointerlock.html
- Three.js — `Raycaster` (`setFromCamera`, `intersectObjects`): https://threejs.org/docs/#api/en/core/Raycaster
- Three.js — layered/overlay rendering (`autoClear`, `clearDepth`, `Layers`): https://threejs.org/docs/#api/en/core/Layers
- Rapier — Kinematic character controller: https://rapier.rs/docs/user_guides/javascript/character_controller
- Gaffer On Games — Fix Your Timestep (projectile/physics stability): https://gafferongames.com/post/fix_your_timestep/
@@ -0,0 +1,97 @@
# Genre Playbook — 2D Platformer
How to build a browser 2D platformer whose jump *feels* good and whose collisions don't glitch. Default engine: **Phaser** (Arcade Physics) or a hand-rolled AABB loop on Canvas. Read `../threejs-foundational.md` first (delta time, Phaser scene/pooling rules). This file focuses on the two things that make or break a platformer: **jump feel** and **tile collision**.
---
## 1. Core mechanics (minimal-but-good scope for a demo)
A great-feeling platformer demo needs:
1. **A responsive jump** with the "forgiveness" features below (coyote time, jump buffer, variable height).
2. **Solid tilemap/AABB collision** — no sticking to walls, no falling through floors, no tunneling at speed.
3. **One-way (drop-through) platforms.**
4. **A few hazards + a goal** (spikes/pit → respawn at last checkpoint; flag/door to win).
5. **A camera that follows with a deadzone** and doesn't jitter.
That's a complete micro-game. Skip enemies with AI, wall-jump/dash, and moving-platform edge cases until the core jump + collision feel perfect. **Jump feel is the whole genre — spend your polish budget there.**
---
## 2. Jump feel — the forgiveness features (do ALL of these)
These are the difference between "floaty/frustrating" and "tight." Popularized by Celeste and GMTK's "Platformer Toolkit."
- **Coyote time (~80120 ms):** allow a jump for a few frames *after* the player walks off a ledge. Store `coyoteTimer`; reset to `COYOTE_TIME` while grounded, decrement each frame, and allow a jump if `coyoteTimer > 0`. Prevents "I pressed jump but it didn't register at the edge."
- **Jump buffering (~100150 ms):** if the player presses jump slightly *before* landing, remember it and jump the instant they touch ground. Store `jumpBufferTimer` on keypress; on landing, if `jumpBufferTimer > 0`, jump immediately.
- **Variable jump height:** short tap = short hop, hold = full jump. On jump, set upward velocity to max. On **key release while still rising** (`velocityY < 0`), cut the velocity (e.g. `velocityY *= 0.5`). Do NOT set velocity to 0 or it looks abrupt.
- **Asymmetric gravity:** apply **higher gravity while falling** than while rising (e.g. fall gravity ×1.52.5), and often a lower gravity near the jump apex ("apex hang") plus a small horizontal speed boost at apex. This is the single biggest "feel" upgrade — a pure parabola feels floaty.
- **Clamp fall speed** (terminal velocity) so long falls stay controllable and collision stays stable.
- **Instant-ish horizontal control:** high ground acceleration + high friction so direction changes feel snappy; reduced acceleration + a little air drag in the air. Avoid slippery ice-feel unless intended.
Combine coyote + buffer + variable height + asymmetric gravity and even a placeholder box "feels like Mario."
---
## 3. Collision — tilemap / AABB (the #1 bug source)
- **Separate the axes: resolve X, then Y (or Y then X) independently.** Move on X, resolve X collisions; then move on Y, resolve Y collisions. Resolving both at once with a single overlap vector causes corner snags and wall-sticking.
- **Use AABB (axis-aligned bounding box) vs the tile grid.** For each move, compute which tiles the player's box overlaps (world→tile via `floor(pos / tileSize)`), and push the player out of solid tiles along the axis being resolved. This is O(overlapped tiles), not O(all tiles).
- **Grounded detection:** you are grounded when a downward Y-resolution stopped you this frame (or a 1px probe below finds a solid tile). Set `velocityY = 0` on landing and on hitting a ceiling.
- **Prevent tunneling at high speed:** if the player can move more than ~half a tile per frame, do **swept collision** or sub-step the movement (loop in small increments) so you never skip over a thin floor. Fixed timestep helps.
- **Don't get stuck in seams between tiles:** when scanning multiple solid tiles, resolve using the collision on the axis of motion and skip internal edges (a tile that is adjacent to another solid tile shouldn't push you sideways). Merging colliders for runs of tiles, or ignoring faces between two solids, fixes the classic "snag on a flat floor" bug.
- **Phaser:** use `map.setCollisionByProperty({ collides: true })` / `setCollisionBetween`, then `this.physics.add.collider(player, layer)`. Set `tile.setCollision(...)` per-side for one-ways. Arcade physics handles separation for you — trust it before hand-rolling.
---
## 4. One-way (drop-through) platforms
- **Collide only when moving downward and coming from above.** The player passes up through the platform but lands on top.
- **Implementation:** only resolve the collision if `velocityY >= 0` (falling) AND the player's *previous* bottom was above the platform's top last frame. Otherwise ignore it.
- **Drop-through:** press Down (+ jump) to temporarily disable the current one-way platform for a few frames so the player falls through.
- **Phaser Arcade:** set `tile.setCollision(false,false,true,false)` (top-only) or use `body.checkCollision.down`; or use `collider`'s `processCallback` to return false when the player is below the platform.
---
## 5. Camera
- **Follow with a deadzone / soft zone**, not rigid lock — the camera only moves when the player leaves a central box, which kills micro-jitter. Phaser: `this.cameras.main.startFollow(player, true, lerpX, lerpY)` + `setDeadzone(w, h)`.
- **Add look-ahead** in the direction of movement so the player sees where they're going.
- **Clamp the camera to level bounds** (`setBounds`) so you never see past the edges.
- **Round the camera position to whole pixels** for pixel-art games to avoid shimmer; pair with `pixelArt: true` / `roundPixels: true`.
- Smooth vertical follow separately (or snap on landing) so jumps don't make the camera bounce.
---
## 6. Common bugs to avoid (checklist)
- **Floaty jump** → add asymmetric gravity (heavier falling), apex hang, variable height, terminal velocity.
- **"Jump didn't register" at edges / just before landing** → missing coyote time / jump buffering.
- **Sticking to walls / snagging on flat floors** → resolving both axes at once, or not ignoring seams between adjacent solid tiles. Resolve axes separately.
- **Falling through floor at high speed** → tunneling; sub-step or swept collision + fixed timestep + clamp fall speed.
- **Can't drop through / falls through one-ways when jumping up** → one-way check must gate on downward velocity + coming-from-above.
- **Double/infinite jump** → grounded flag not reset correctly; only allow jump when grounded OR coyote timer > 0, and consume the buffer.
- **Camera jitter** → no deadzone / not pixel-rounded / following unsmoothed physics position.
- **Movement frame-rate dependent** → scale by delta (Phaser gives you `delta`; Arcade integrates for you, but custom code must use it).
- **Getting stuck when spawning inside a tile** → validate spawn/respawn points are in empty space.
---
## Defaults to apply
1. **Always implement the "forgiveness trio": coyote time (~0.1s), jump buffering (~0.12s), and variable jump height (cut velocity on early release).** These are cheap and are the difference between a good and bad platformer. Bake them into any generated platformer by default.
2. **Use asymmetric gravity (heavier when falling) + terminal velocity + apex hang.** Never ship a symmetric-parabola jump.
3. **Resolve collision one axis at a time (X then Y) against an AABB/tile grid; sub-step fast movement to prevent tunneling.** Prefer Phaser Arcade's collider over hand-rolled math when possible.
4. **One-way platforms: collide only when falling and coming from above; Down = drop through.**
5. **Camera: follow with a deadzone + look-ahead, clamp to bounds, round to pixels for pixel art.**
6. **Minimal scope:** tight jump + solid collision + one-ways + hazards/checkpoint + goal. Polish the jump before adding enemies.
---
## Sources
- GMTK — "Why Does Celeste Feel So Good to Play" / Platformer Toolkit (coyote time, buffering, variable jump): https://www.youtube.com/watch?v=yorTG9at90g
- GMTK Platformer Toolkit (interactive): https://gmtk.itch.io/platformer-toolkit
- "Coyote Time and Jump Buffering" (Nathan Hoad / common write-ups): https://nathanhoad.net/coyote-time-and-jump-buffering
- MDN — 2D breakout/AABB collision detection basics: https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
- Phaser — Tilemaps & Arcade Physics collision: https://docs.phaser.io/phaser/concepts/tilemaps and https://docs.phaser.io/phaser/concepts/physics/arcade
- "Rendering Tile-Based Worlds" / one-way platforms (MaddyThorson / Celeste dev notes): https://maddythorson.medium.com/
- Red Blob Games — grid/tile math reference: https://www.redblobgames.com/
@@ -0,0 +1,131 @@
# Genre Playbook — Grid Puzzle (Match-3 & Tetris)
Grid-based puzzlers (Bejeweled/Candy Crush match-3, Tetris) live or die on **a clean grid data model** and **exactly-correct board logic**. Both are 2D — Canvas/Phaser/DOM all work; the board is data, the rendering is a thin view. Read `../threejs-foundational.md` first (delta time, pooling). This file gives the precise algorithms, including the full **SRS wall-kick tables** (verbatim from the Tetris wiki).
---
## 0. The golden rule: separate model from view
**The board is a 2D array of ints/enums; rendering reads from it.** Never store game state in sprite positions or the DOM. All logic (matches, gravity, rotation, collision) runs on the array; then you sync visuals (tween pieces to their cell). This prevents the entire class of "the animation and the logic disagree" bugs.
---
# Part A — Match-3 (Bejeweled / Candy Crush)
## A1. Core mechanics & minimal scope
1. A **grid** (e.g. 8×8) of colored gems (`grid[row][col] = colorId`).
2. **Swap two adjacent gems** (click-click or drag); the swap is only legal if it creates a match.
3. **Match detection** (3+ in a row/column) → clear.
4. **Gravity** (gems above fall into gaps) + **refill** (new gems drop from the top).
5. **Cascades** (chain reactions) scored with a multiplier, and a score/moves HUD.
That's a complete match-3. Add special gems, objectives, and levels later.
## A2. Match detection
- **Scan rows and columns for runs of ≥3 identical colors.** The simplest robust method: for each cell, count consecutive same-color horizontally and vertically; mark any run of length ≥3. Collect all marked cells into a set (so an L/T shape counts once).
- **Flood fill** is the right tool when matches aren't strictly lines (e.g. same-color blobs, or Puyo Puyostyle groups): from each unvisited cell, BFS/DFS to all 4-connected same-color neighbors; if the connected group size ≥ threshold, it's a match. Use flood fill for group-clear variants and for detecting connected clusters after cascades.
- **Swap validation:** perform the swap in the array, run match detection; if no match results, **swap back** (and animate the reversal). Only commit swaps that match.
- **Initial board must have no pre-existing matches** and should have at least one valid move — generate, then re-roll any accidental matches; optionally verify a move exists (shuffle if deadlocked).
## A3. Gravity, refill & cascades
- **Clear** matched cells (set to empty), award score.
- **Gravity:** for each column, compact non-empty cells downward (iterate from the bottom, pull the next non-empty cell down). Do this on the array first.
- **Refill:** fill the now-empty top cells with new random gems.
- **Cascade loop:** after gravity+refill, **run match detection again**; if new matches formed, clear/score them (with an increasing chain multiplier) and repeat until stable. This loop is the satisfying core — don't stop after one pass.
- **Animate after resolving:** compute the whole settled state, then tween gems to their new cells (falling animation). Lock input while the board is resolving.
---
# Part B — Tetris
## B1. Core mechanics & minimal scope
1. A **playfield** (standard 10 wide × 20 visible, plus hidden rows above for spawning).
2. **7 tetrominoes** (I, O, T, S, Z, J, L) each with 4 rotation states.
3. **Gravity** (piece falls on a timer), **soft/hard drop**, **move left/right**, **rotate CW/CCW**.
4. **Line clears** (full rows removed, rows above shift down) + scoring.
5. **Spawn next piece; game over** when a new piece can't spawn.
Guideline-quality Tetris additionally needs: **7-bag randomizer**, **SRS rotation + wall kicks**, **lock delay**, ghost piece, hold, and a next-queue. Do these — they're what players expect.
## B2. Data model & collision
- Represent each piece as a set of 4 cell offsets for its current rotation state, plus a board position `(x, y)`. The board is `grid[row][col]`.
- **Collision test:** a move/rotation is valid iff every one of the piece's 4 cells is in-bounds and lands on an empty board cell. Check *before* committing any move.
- **Move/rotate = test the target; if valid, apply; else reject** (rotation additionally tries wall kicks, below).
- **Line clear:** find full rows, remove them, shift everything above down, and clear the freed top rows. Award by lines cleared at once (1=Single … 4=Tetris) — Tetris (4) scores far more, incentivizing well-building.
## B3. Randomizer, gravity, lock delay
- **7-bag randomizer (mandatory for modern feel):** shuffle a bag of all 7 pieces, deal them out, refill/reshuffle when empty. Guarantees no long droughts and no floods of one piece. Pure random feels bad.
- **Gravity:** drop one row every `fallInterval` (decreasing with level). **Soft drop** = faster fall; **hard drop** = instantly place at the lowest valid position (+ small score per cell).
- **Lock delay (~0.5s):** when a piece lands (can't fall further), don't lock it immediately — give a short window during which moving/rotating keeps it alive. Standard is a **move/rotation reset limited to ~15 resets** (so you can't stall forever). Without lock delay the game feels harsh; with an unlimited reset you can stall infinitely — cap the resets.
- **DAS/ARR:** delayed auto-shift (a pause before a held direction auto-repeats) + auto-repeat rate. Tune these — they define movement feel for skilled play.
## B4. SRS rotation & wall kicks (verbatim tables)
**Rotation states:** `0` = spawn, `R` = one CW from spawn, `L` = one CCW from spawn, `2` = 180°.
When a rotation is attempted, test **5 offsets in order**; use the first that fits; if none fit, the rotation fails entirely.
**⚠️ Coordinate convention:** these tables use **x = right positive, y = UP positive**. If your grid's row index increases *downward* (the usual case), **negate the y value** when applying kicks (a `+2` up becomes `row 2`). Getting this sign wrong is the #1 SRS bug.
**J, L, S, T, Z kick data** (all share one table):
```
0->R: (0,0) (-1,0) (-1,+1) (0,-2) (-1,-2)
R->0: (0,0) (+1,0) (+1,-1) (0,+2) (+1,+2)
R->2: (0,0) (+1,0) (+1,-1) (0,+2) (+1,+2)
2->R: (0,0) (-1,0) (-1,+1) (0,-2) (-1,-2)
2->L: (0,0) (+1,0) (+1,+1) (0,-2) (+1,-2)
L->2: (0,0) (-1,0) (-1,-1) (0,+2) (-1,+2)
L->0: (0,0) (-1,0) (-1,-1) (0,+2) (-1,+2)
0->L: (0,0) (+1,0) (+1,+1) (0,-2) (+1,-2)
```
**I piece kick data** (its own table):
```
0->R: (0,0) (-2,0) (+1,0) (-2,-1) (+1,+2)
R->0: (0,0) (+2,0) (-1,0) (+2,+1) (-1,-2)
R->2: (0,0) (-1,0) (+2,0) (-1,+2) (+2,-1)
2->R: (0,0) (+1,0) (-2,0) (+1,-2) (-2,+1)
2->L: (0,0) (+2,0) (-1,0) (+2,+1) (-1,-2)
L->2: (0,0) (-2,0) (+1,0) (-2,-1) (+1,+2)
L->0: (0,0) (+1,0) (-2,0) (+1,-2) (-2,+1)
0->L: (0,0) (-1,0) (+2,0) (-1,+2) (+2,-1)
```
**O piece does not kick** (it never needs to — its rotation is trivial; only its rotation center appears to move).
**Algorithm:** to rotate, compute the piece cells in the target state (pure rotation about the piece center), then for each of the 5 `(dx, dy)` offsets for that specific `from->to` transition, test the piece translated by `(dx, -dy)` (if y is down); apply the first that fits. If all 5 fail, cancel. This is what enables T-spins and squeezing the I-piece into tight wells.
## B5. Match-3 & Tetris common bugs (checklist)
- **State stored in sprites/DOM instead of an array** → logic/animation desync. Model is the array.
- **(Match-3) Not swapping back on a non-matching swap** → illegal moves stick.
- **(Match-3) Cascade loop stops after one pass** → miss chain reactions; loop until stable.
- **(Match-3) Initial board has matches or no valid moves** → re-roll on generate; detect deadlock and shuffle.
- **(Tetris) Pure random piece order** → droughts feel awful; use a 7-bag.
- **(Tetris) SRS y-sign flipped** → wall kicks push pieces the wrong way; negate y for a downward-row grid.
- **(Tetris) Wrong/omitted kick table** → no T-spins, I-piece won't fit tight spots; use the exact 5-offset tables per transition (I has its own; O doesn't kick).
- **(Tetris) No lock delay, or unlimited reset** → either too harsh, or you can stall forever; cap resets (~15).
- **(Tetris) Line clear shifts rows incorrectly** → clear from the model, shift above down, clear freed top rows; test multi-line clears.
- **(Tetris) Collision checked after committing** → test the target position first, commit only if valid.
- **Timing tied to frame rate** → gravity/fall interval must use delta/real time.
---
## Defaults to apply
1. **Model = 2D array; view is a thin renderer that tweens to cells.** Run all logic on the array, then animate. Lock input while resolving.
2. **Match-3: swap → detect → (if no match) swap back; clear → gravity → refill → re-detect in a cascade loop until stable.** Use line-scan for line matches and **flood fill** for connected-group/blob matches. Generate boards with no initial matches and at least one move.
3. **Tetris: implement the full guideline — 7-bag randomizer, SRS with the exact wall-kick tables above, lock delay with capped resets, hard/soft drop, ghost piece, hold, next queue.** Players expect all of it.
4. **SRS: mind the y-sign** (tables are y-up; negate for row-down grids). I-piece uses its own kick table; O doesn't kick.
5. **Always test a move/rotation against the model before committing; reject if any cell is out of bounds or occupied.**
6. **Both genres are timer-driven** — use delta/real time for fall speed and animations, never per-frame.
---
## Sources
- Hard Drop Tetris Wiki — SRS (spawn, rotation, and the verbatim wall-kick tables above): https://harddrop.com/wiki/SRS
- Hard Drop Tetris Wiki — Wall kick: https://harddrop.com/wiki/Wall_kick
- Hard Drop Tetris Wiki — Lock delay: https://harddrop.com/wiki/Lock_delay
- Hard Drop Tetris Wiki — Random Generator (7-bag): https://harddrop.com/wiki/Random_Generator
- Tetris Guideline (official standard): https://tetris.wiki/Tetris_Guideline
- "Tetris" implementation guide (javidx9 / general): https://tetris.wiki/Tetris_(NES,_Nintendo)
- Wikipedia — Flood fill (BFS/DFS): https://en.wikipedia.org/wiki/Flood_fill
- Bejeweled/Candy Crush match-3 algorithm write-ups (match detection + cascade): https://www.emanueleferonato.com/2018/07/13/build-a-html5-match-3-game-using-phaser/
@@ -0,0 +1,106 @@
# Genre Playbook — Arcade Racing / Kart
Arcade racing (Mario Kart, not a sim) is about **forgiving, exciting handling**, satisfying **drift**, and correct **lap/checkpoint** logic. Default engine: **Three.js/Babylon.js** (3D) with a simple arcade car model — do **not** reach for a full rigid-body vehicle sim for a demo. Read `../threejs-foundational.md` first (delta time, follow camera, forward-axis convention, disposal).
---
## 1. Core mechanics (minimal-but-good scope for a demo)
1. **Arcade car handling** — accelerate, brake/reverse, steer; grippy but slidey (see §2).
2. **Drift** with a boost payoff (the fun core of kart racers).
3. **A looping track** with walls/boundaries and ground collision.
4. **Checkpoints + lap counting** that can't be cheated by cutting the course.
5. **A follow (chase) camera** and a HUD (speed, lap X/N, lap time).
One car, one track, 3 laps, a lap timer, and drift-boost is a complete game. Add opponents (even simple waypoint-followers), items, and multiple tracks later. **Handling feel + drift are the whole genre — tune them first.**
---
## 2. Arcade handling (pseudo-physics, not a sim)
Model the car as a point/box with a **heading (yaw)**, a **forward speed**, and a small amount of lateral slide. Avoid full tire-slip simulation.
- **Acceleration:** `speed += throttle * accel * delta`, clamp to `maxSpeed`. Reverse/brake applies negative accel with a lower cap.
- **Drag + rolling resistance:** each frame `speed *= (1 - drag*delta)` (and stronger when off throttle) so the car coasts to a stop. Add higher drag off-track (grass/sand) to punish going wide.
- **Steering scales with speed, not constant:** turn rate should be low at very low speed (can't turn a parked kart much) and taper at very high speed so it isn't twitchy. A common trick: `turn = steerInput * baseTurn * clamp(speed / someSpeed, 0, 1)`. Reverse the steering sign when moving backward.
- **Steer sign (must not invert):** full rules live in the **`controls` skill**
(`.grok/skills/controls/SKILL.md`) — open it for **any** vehicle, not only
karts. Summary: while `speed > 0` and the chase camera is behind the car,
**A / ← turns left**, **D / → turns right**. With
`forward = (-sin(yaw), 0, -cos(yaw))`, **KeyA must yield +yaw**
(`steer = +1` on A, then `yaw += steer * turnRate * …`). The classic bug is
`KeyA → steer = -1` with `yaw += steer * +rate` (A turns right). Run the
`controls` self-test before shipping; do **not** use FPS strafe as a steer test.
- **Apply yaw, then move along heading:** rotate the car by `turn * delta`, then translate along its forward vector by `speed * delta`. Keep a little **lateral velocity** that decays (grip) so the car feels weighty rather than on rails.
- **Grip model (simple):** split velocity into forward and sideways components; kill most of the sideways component each frame (high grip) — reducing how much you kill it is exactly what creates drift.
- **Keep the car on the ground:** raycast down to the track to set height/normal (handles hills/banking) rather than full suspension. Align the car's up to the surface normal for looks.
---
## 3. Drift (the payoff mechanic)
- **Drift = temporarily reduce lateral grip** (let more sideways velocity survive) while the player holds a drift/handbrake button and steers, so the car slides through the corner with the nose pointing inward.
- **Hop-then-drift (kart style):** a small hop initiates the drift; holding it while turning builds a **drift charge** over time.
- **Mini-turbo / boost payoff:** the longer/tighter the drift, the bigger the speed boost on release (stage it: blue → orange sparks). This risk/reward loop *is* kart racing.
- **Visual/audio feedback:** tire-skid marks (decals), drift particles/sparks that change color with charge, tire-screech sound, and a slight camera FOV kick on boost. Feedback sells the drift.
- **Countersteer feel:** while drifting, let the player modulate the slide angle with steering; snap back to grip smoothly on release (don't instantly zero the lateral velocity — lerp it).
---
## 4. Checkpoints & laps (get this right or laps break)
- **Place ordered checkpoints around the track** (invisible trigger volumes/gates), including a start/finish line. Store the count `N`.
- **Require sequential passing:** track `nextCheckpoint`. A checkpoint only counts if it's the expected next one; passing them out of order (or driving backward) does nothing. This **prevents lap-skipping / reverse-cheesing** — the classic bug where crossing the finish line repeatedly racks up laps.
- **Count a lap** only when the player crosses the finish line *after* hitting all checkpoints for that lap; then reset `nextCheckpoint` to 0.
- **Detect crossing with trigger overlap** (AABB/sphere against the car), and for fast cars use a **swept/segment test** (did the car's path this frame cross the gate plane?) so you don't tunnel through a thin checkpoint at high speed.
- **Respawn / rescue:** if the car flips, leaves the track, or stalls, respawn it at the **last passed checkpoint** facing forward. Also use checkpoints for "wrong way" detection.
- **HUD:** current lap / total, current + best lap time, position. Freeze the timer at finish.
---
## 5. Camera
- **Chase camera behind & slightly above the car**, following with **lerp/spring** (position and look-at both smoothed) so it lags a touch and swings on turns — this conveys speed. `camera.position.lerp(targetBehindCar, k)`; `camera.lookAt(carPosition + lookAhead)`.
- **Speed FOV:** widen FOV slightly at high speed / on boost and narrow when slow — a strong, cheap sense of speed.
- **Don't rigidly parent the camera to the car** or it feels stiff and induces motion sickness; smoothing is essential. Add a tiny bit of positional damping, not rotational snapping.
- Offer a couple of views (chase / hood) if easy. Add motion lines / ground blur / roadside object density for speed sensation.
- Mind the forward-axis convention (camera looks down Z; car "front" should be +Z or handled via `lookAt`).
---
## 6. Common bugs to avoid (checklist)
- **Lap counter increments on any finish-line crossing** → require all checkpoints in order before counting a lap.
- **Player skips/cuts the track to cheat laps** → sequential-checkpoint gating + wrong-way detection.
- **Car tunnels through walls/checkpoints at speed** → swept/segment collision, not just per-frame overlap; consider fixed timestep.
- **Steering identical at all speeds** → twitchy when fast, unturnable when slow; scale turn rate by speed and flip sign in reverse.
- **A/D reversed while driving forward**`steerInput` sign disagrees with the
`forward`/`yaw` basis; re-check the vehicle self-test (A turns left at speed > 0),
not the FPS D→+X strafe test.
- **Car feels on rails or uncontrollably slidey** → tune the grip (how much lateral velocity you kill per frame); drift = temporarily reduce that.
- **Camera stiff / nausea-inducing** → smooth (lerp/spring) both position and look-at; don't hard-parent.
- **Movement/steer not delta-scaled** → frame-rate dependent handling.
- **Car floats above/sinks into hills** → raycast to ground for height + surface normal each frame.
- **No off-track penalty** → add drag/slowdown on grass so cutting corners costs speed.
---
## Defaults to apply
1. **Use arcade pseudo-physics, not a rigid-body sim:** heading + forward speed + decaying lateral velocity; drag for coasting; steering that scales with speed. Grippy-but-slidey.
2. **Ship drift with a boost payoff** (reduce lateral grip while held + charge → mini-turbo on release, with sparks/skid feedback). This is the genre's hook.
3. **Lap logic MUST use ordered checkpoints:** a lap counts only after all checkpoints are passed in sequence, then the finish line. Prevents skip/reverse cheating. Respawn at last checkpoint.
4. **Use swept collision for walls and checkpoint gates** so fast cars don't tunnel.
5. **Chase camera with smoothing + speed-based FOV** for the sense of speed; never hard-parent.
6. **Minimal scope:** one car, one looping track with checkpoints/laps/timer, drift-boost, HUD. Opponents/items come later.
7. **Steer self-test before done:** `speed > 0`, hold A → turns left; hold D → turns right (chase cam). Fail = not done.
---
## Sources
- "How to make an arcade car / kart handling model" (community write-ups, e.g. Kenney/CodinGame arcade car): https://github.com/spacejack/carphysics2d and demo https://spacejack.github.io/carphysics2d/
- Marco Monster — "Car Physics for Games" (classic arcade/sim reference): https://asawicki.info/Mirror/Car%20Physics%20for%20Games/Car%20Physics%20for%20Games.html
- Three.js — chase/follow camera & `Object3D.lookAt`: https://threejs.org/docs/#api/en/core/Object3D.lookAt
- Three.js — `Raycaster` (ground/height sampling): https://threejs.org/docs/#api/en/core/Raycaster
- Gaffer On Games — Fix Your Timestep (stable vehicle integration / no tunneling): https://gafferongames.com/post/fix_your_timestep/
- Red Blob Games — line-segment intersection (checkpoint crossing tests): https://www.redblobgames.com/
@@ -0,0 +1,97 @@
# Genre Playbook — Top-Down / Twin-Stick Shooter
Top-down action where you **move with one input and aim with another** (mouse+WASD on desktop, two virtual sticks on mobile, or left/right stick on gamepad). Think Enter the Gungeon, Nuclear Throne, Vampire Survivors, .io shooters. Default engine: **Phaser** (2D) or Three.js with an orthographic/top camera. Read `../threejs-foundational.md` first (delta time, pooling, mobile joysticks).
---
## 1. Core mechanics (minimal-but-good scope for a demo)
1. **Decoupled move + aim** (the defining feature — see §2).
2. **A shooting weapon** with pooled bullets and a fire-rate cooldown.
3. **Enemies that spawn and chase** the player (simple seek/steering), with hit + death.
4. **Collision:** player↔walls, bullets↔enemies, enemies↔player (damage), plus screen/arena bounds.
5. **HUD:** health + score/wave; a game-over + restart.
A single weapon + one enemy type spawning in waves + juicy hit feedback is already a fun loop (Vampire Survivors shipped on essentially this). Add pickups/upgrades only after the core loop is fun.
---
## 2. Controls & aiming (the twin-stick core)
- **Movement is independent of facing.** Move on a normalized vector; aim on a separate vector. This is *the* genre feature — never couple them (that's a tank/first-person control scheme, not twin-stick).
- **Desktop:** WASD/arrows → move vector; mouse position → aim. Aim angle = `Math.atan2(mouse.y - player.y, mouse.x - player.x)` **in world space** (convert screen→world with the camera scroll/zoom, not raw clientX/Y). Left click = fire.
- **Gamepad:** left stick = move, right stick = aim/fire (fire when the right stick is deflected past a deadzone). Apply a **radial deadzone** (`if (len < 0.2) zero it`) and optionally re-scale so the edge maps to full speed.
- **Mobile:** two virtual joysticks (e.g. nipplejs) — left = move, right = aim/fire. Auto-fire while the aim stick is held is friendlier than a separate button.
- **Normalize the movement vector** before applying speed so diagonal movement isn't ~1.41× faster (the classic "faster on diagonals" bug). `vec.normalize().scale(speed * delta)`.
- **Rotate the sprite/mesh to the aim angle**, not the move angle. In Phaser remember sprites face "right" (0 rad) by default; offset art accordingly. In Three.js top-down, rotate around the up axis and mind the +Z/Z forward convention.
- **Optional auto-aim / aim-assist** (snap to nearest enemy) is great on mobile and for accessibility.
---
## 3. Camera
- **Follow the player centered**, ideally with a small deadzone and **look-ahead toward the aim/mouse** (offset the camera partway toward the cursor) so you see more of where you're shooting.
- **Clamp to level bounds**; for arena games, a fixed camera showing the whole arena is also fine.
- Keep it smooth (lerp) but not laggy; snap on teleport/respawn.
- Phaser: `cameras.main.startFollow(player, true, 0.1, 0.1)` + `setBounds` + `setDeadzone`.
---
## 4. Bullets, enemies & performance
- **Pool everything.** Twin-stick games spawn hundreds of bullets and enemies. Use object pools (Phaser Groups with `maxSize`, or a preallocated array) — never create/destroy per shot. Recycle by toggling `active/visible`.
- **Bullets:** move by velocity·delta; despawn on wall hit, enemy hit, or when off-screen/out of range/TTL. Prefer circle-vs-circle overlap for bullet↔enemy (cheap and forgiving).
- **Enemy steering:** simplest good-enough is **seek** — velocity toward `(player - enemy).normalize() * speed`. Add **separation** (push apart from nearby enemies) so they don't stack into one blob. For walls, either use physics colliders or simple flow-field/grid pathfinding for larger maps.
- **Broad-phase collision** when counts are high: spatial hash / uniform grid so you only test nearby pairs, not O(n²). Phaser Arcade has a grid/tree internally; hand-rolled Canvas needs its own.
- **Spawn waves** with a timer + difficulty curve (rate and enemy count rising over time). Spawn off-screen at the arena edge, not on top of the player.
- **Cap concurrent entities** to protect frame rate; degrade gracefully (fewer particles) on mobile.
---
## 5. Genre-specific "feel" (the juice)
- **Screen shake** on shooting and explosions (small, decaying).
- **Hit flash** (tint enemy white for 12 frames) + **knockback** + **hit-stop** (freeze the game for a few ms on a big hit) — hit-stop is a huge, cheap feel win.
- **Muzzle flash, bullet tracers, and impact particles.**
- **Enemy death**: pop into particles/gibs + a sound + score popup + occasional slow-mo on a big kill.
- **Weapon feel:** spread/recoil for shotguns, fast tiny bullets for SMGs; controller rumble if available.
- **Readable bullets:** player bullets and enemy bullets must be visually distinct (color/size) so the player can dodge — critical in bullet-hell density.
- **Sound** for fire, hit, death, pickup; unlock audio on first gesture.
---
## 6. Common bugs to avoid (checklist)
- **Faster movement on diagonals** → normalize the move vector before scaling by speed.
- **Coupling aim to movement** → they must be independent (that's the whole genre).
- **Aiming off by camera offset/zoom** → convert screen coords to world coords using the camera; don't use raw clientX/Y.
- **Sprite faces wrong direction** → account for the engine's default facing (Phaser sprites face +X/0 rad) and 3D forward-axis convention.
- **No radial deadzone on gamepad** → stick drift makes the player spin/creep.
- **Creating/destroying bullets & enemies each frame** → GC stutter; pool them.
- **O(n²) collision with many entities** → use a spatial grid/hash for broad-phase.
- **Enemies stacking into one sprite** → add separation steering.
- **Enemies spawning on top of the player** → spawn at arena edges/off-screen.
- **Bullets living forever** → give them a TTL/range and despawn off-screen.
- **Movement not delta-scaled** → frame-rate dependent speed.
---
## Defaults to apply
1. **Decouple move and aim by default** — WASD/left-stick move + mouse/right-stick aim; auto-fire on mobile. This one choice defines the genre.
2. **Always normalize the movement vector** so diagonals aren't faster; apply a **radial deadzone** for sticks; convert mouse→world coords for aim angle.
3. **Pool bullets and enemies; use a spatial grid for collision** when counts get high; cap entities on mobile.
4. **Enemy AI = seek + separation** (+ simple wave spawner at edges). Good enough to be fun without pathfinding.
5. **Feel = hit flash + hit-stop + screen shake + score popups + distinct player/enemy bullet colors.** Cheap, huge payoff.
6. **Minimal scope:** one weapon, one enemy type, wave spawner, health/score HUD, game-over/restart. That's a complete loop.
---
## Sources
- MDN — 2D collision detection (circle/AABB): https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
- Red Blob Games — 2D visibility, grids, pathfinding, and vector math: https://www.redblobgames.com/
- Steering Behaviors (Reynolds — seek/flee/separation), classic reference: https://www.red3d.com/cwr/steer/
- Phaser — cameras (follow, deadzone, bounds): https://docs.phaser.io/phaser/concepts/cameras
- Phaser — Groups & object pooling: https://docs.phaser.io/phaser/concepts/gameobjects/group
- nipplejs (mobile virtual joysticks): https://github.com/yoannmoinet/nipplejs
- MDN — Gamepad API (deadzones, axes): https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API
@@ -0,0 +1,84 @@
# Genre Playbook — Tower Defense
Place towers on a grid to stop waves of enemies walking a path to your base. The genre is fundamentally about **a grid, pathfinding, targeting, and economy**. 2D (Phaser/Canvas) or 3D top-down (Three.js). Read `../threejs-foundational.md` first (delta time, pooling, top-down camera).
---
## 1. Core mechanics (minimal-but-good scope for a demo)
1. **A grid map** with a defined enemy **path** from spawn to the base.
2. **Enemies that walk the path** in timed **waves**, with HP and speed.
3. **Tower placement** on buildable cells, costing money.
4. **Towers that target + shoot** enemies in range (projectile or hitscan), dealing damage.
5. **Economy + lives:** kills give gold; leaks cost lives; game over at 0 lives. Wave counter + "start next wave" button.
One tower type, one enemy type, a fixed path, 510 escalating waves, gold, and lives is a complete game. Add tower types/upgrades, enemy variety (fast/armored/flying), and maze-building later. **Fixed-path TD is much simpler and less bug-prone than maze/free-build TD — default to it for demos.**
---
## 2. Map, path & pathfinding
- **Two flavors — pick deliberately:**
- **Fixed path (recommended default):** the path is authored (a polyline / ordered list of waypoints). Enemies just follow waypoints. No runtime pathfinding, no way to trap enemies — far fewer bugs.
- **Maze / free-build:** players place towers anywhere and enemies pathfind around them (Bloons-style is fixed; Desktop TD is maze). Requires **A\*** re-pathing and a critical rule: **never allow a placement that fully blocks the path** — validate with a pathfinding check before committing, and reject if no route remains.
- **Grid model:** a 2D array of cells with types (path / buildable / blocked / spawn / base). Convert world↔grid with `floor(pos / cellSize)`. Snap tower placement to cell centers.
- **A\* (maze TD):** grid neighbors (4- or 8-connected), Manhattan/octile heuristic. Recompute affected enemies' paths when a tower is placed/sold. Cache the path; only recompute on change. See Red Blob Games' A* guide (canonical).
- **Waypoint following (fixed path):** each enemy stores its target waypoint index; move toward it by `speed*delta`; when within a small epsilon, advance to the next; reaching the end = a "leak" (lose a life, despawn).
---
## 3. Towers: targeting, range & firing
- **Range check:** distance (squared, to avoid sqrt) from tower to enemy ≤ range². Use a **spatial grid/quadtree** for broad-phase if there are many enemies — don't test every tower against every enemy each frame (O(towers×enemies)).
- **Targeting policy (make it explicit & selectable):** common options — **First** (furthest along the path, the usual default), **Last**, **Closest**, **Strongest (most HP)**, **Weakest**. "First" is the standard because it stops leaks best. Ambiguous/implicit targeting is a common complaint — pick and document one.
- **Fire rate:** cooldown timer per tower (`if (now - lastFire >= 1/fireRate) fire()`), delta-based, not per-frame.
- **Projectiles vs hitscan:** projectiles (pooled) that travel and can **home** on the target or aim at a **lead/predicted position** feel better and enable "miss if enemy dies mid-flight." Hitscan (instant damage + a drawn beam) is simpler and fine for lasers. Pool projectiles.
- **Damage types & AoE:** splash damage hits enemies within a radius of impact; slow/poison apply timed status effects. Even one splash tower adds a lot of depth.
- **Rotate the tower/turret to face its target** (mind facing conventions). Show range as a ring on hover/selection.
- **Upgrades/selling:** click a tower → upgrade (more damage/range/rate) or sell for partial refund.
---
## 4. Waves & economy (the balance core)
- **Wave data as configuration**, not hardcoded logic: a list of waves, each a list of `{enemyType, count, spawnInterval, delay}`. Easy to tune and extend.
- **Escalation:** later waves have more/tougher/faster enemies; introduce armored (reduce non-piercing damage), fast, and flying (only certain towers hit) types over time.
- **Economy loop:** enemies give gold on death; towers/upgrades cost gold; a leak costs a life. Tune so the player is always a little short — that tension is the game. Give inter-wave prep time and optionally a bonus for starting the next wave early.
- **Lives + win/lose:** lose a life per leak, game over at 0; win by surviving all waves (or endless mode with scaling).
- **Boss waves** for pacing spikes.
---
## 5. Common bugs to avoid (checklist)
- **(Maze TD) Placement that fully blocks the path** → always run a pathfinding validity check before allowing placement; reject if no route to base remains.
- **Enemies overshoot/orbit waypoints** → advance when within an epsilon distance and clamp the final step so they don't jitter around the point.
- **O(towers×enemies) targeting each frame** → use squared-distance checks + a spatial grid/quadtree for broad-phase.
- **Ambiguous targeting** → choose an explicit policy (default "First"), make it consistent and ideally player-selectable.
- **Projectiles chasing dead enemies / never expiring** → give projectiles a target ref + TTL; on target death, either retarget, continue to last position, or despawn.
- **Fire rate frame-rate dependent** → cooldown by delta time, not per frame.
- **Building on path/occupied cells** → validate cell type + occupancy before placing; snap to grid.
- **Economy exploits** → full-refund sell + rebuild loops; use partial refunds; validate gold on every transaction.
- **Not pooling enemies/projectiles** → GC stutter in big waves.
- **Enemies overlapping into one blob** → optional separation, or just accept single-file on a fixed path.
---
## Defaults to apply
1. **Default to fixed-path (waypoint) TD** for demos — no runtime pathfinding, dramatically fewer bugs. Offer maze/A* only when explicitly wanted.
2. **If maze/free-build: NEVER allow a tower placement that fully blocks the path** — validate with an A* reachability check before committing.
3. **Grid-based map + snap placement; validate cell buildability + gold before placing.**
4. **Explicit, selectable targeting policy (default "First"); squared-distance range checks + spatial grid** for performance. Pool projectiles/enemies.
5. **Wave and enemy stats as data/config, not code** — makes balancing and expansion trivial.
6. **Tune the economy tight** (gold from kills, cost of towers, lives from leaks) — the shortage-driven tension is the fun. Minimal scope: 1 tower, 1 enemy, fixed path, escalating waves, gold + lives.
---
## Sources
- Red Blob Games — Introduction to A* / pathfinding (the canonical grid pathfinding reference): https://www.redblobgames.com/pathfinding/a-star/introduction.html
- Red Blob Games — Grids, hexagons, and tile math: https://www.redblobgames.com/grids/
- MDN — 2D collision / distance checks: https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
- Phaser — Groups & pooling (projectiles/enemies): https://docs.phaser.io/phaser/concepts/gameobjects/group
- "Tower Defense targeting priorities" (Bloons/Desktop TD community references): https://bloons.fandom.com/wiki/Targeting_Priority
- Amit Patel / Red Blob — implementation notes on flow fields for many-agent pathing: https://www.redblobgames.com/pathfinding/tower-defense/
@@ -0,0 +1,89 @@
# Genre Playbook — Voxel / Minecraft-like
Block worlds you can walk, build, and mine. In the browser this is a **Three.js/WebGL performance problem first, game second** — the naive approach (one cube mesh per block) melts instantly. The whole genre is about **chunking, face culling, greedy meshing, and voxel raycasting**. Read `../threejs-foundational.md` first (draw calls, disposal, instancing, first-person controls).
---
## 1. Core mechanics (minimal-but-good scope for a demo)
1. **A voxel world** stored as data (block IDs in a 3D grid), rendered as merged chunk meshes.
2. **First-person movement + collision** against blocks (AABB vs voxels).
3. **Break and place blocks** via a voxel raycast (crosshair targets a block/face).
4. **A few block types** with distinct textures (atlas).
5. **Some world generation** (flat, or simple noise terrain) and chunk streaming around the player.
Flat world + walk + place/break + 34 block types is already recognizably "Minecraft." Skip inventory depth, lighting/AO, water, and mobs until the core render + edit loop is solid. **If you render each block as its own mesh, nothing else matters — you'll be at single-digit FPS.**
---
## 2. Chunking (the foundational structure)
- **Divide the world into chunks** (commonly 16×16×256, or 16×16×16 sub-chunks). Store each chunk's blocks in a **flat typed array** (`Uint8Array` indexed `x + y*SX + z*SX*SY`), not nested objects — cache-friendly and memory-light.
- **One merged mesh per chunk** (a single `BufferGeometry` built from all visible faces), not one mesh per block. This is the single most important rule — it turns thousands of draw calls into one per chunk.
- **Rebuild a chunk's mesh only when its blocks change** (place/break), not every frame. Cache the geometry; dispose the old one on rebuild.
- **Stream chunks** around the player: load/generate + build meshes for chunks within a radius; unload (and **dispose geometry/material/textures**) chunks that fall out of range. Do meshing off the main thread (**Web Workers**) to avoid frame hitches; transfer the typed arrays.
- **Neighbor awareness:** meshing a chunk needs to know the blocks in adjacent chunks (to cull faces at chunk borders correctly) — pass neighbor data or a padded copy.
---
## 3. Face culling & greedy meshing (the performance core)
- **Face culling first (mandatory):** only emit a block face if the neighboring block in that direction is **empty/transparent**. Interior faces between two solid blocks are never seen — skipping them removes the vast majority of geometry. This alone makes voxel worlds viable.
- **Greedy meshing (big win):** after culling, **merge adjacent coplanar faces of the same block type into larger quads.** Instead of one quad per block face, a flat 16×16 grass top becomes (ideally) one quad. Algorithm (per 0fps.net "Meshing in a Minecraft Game"): for each of the 6 face directions, sweep the volume slice-by-slice, build a 2D mask of visible faces of each type, then greedily expand rectangles over the mask (grow width, then height while cells match), emitting one quad per merged rectangle. Cuts vertex count and draw work dramatically.
- **Trade-off:** greedy meshing complicates per-face texturing/UVs and lighting/AO (merged quads span multiple blocks). For a demo, culled per-face meshing is acceptable; add greedy meshing when you need scale. Use a **texture atlas** (or a texture array) so all block textures share one material → one draw call per chunk. Set atlas textures to `NearestFilter` and mind bleeding at atlas tile edges (add padding or use a texture array).
- **Don't use `InstancedMesh` of cubes for terrain** — it still submits all 6 faces per block and per-face culling/greedy meshing wins massively. Instancing is fine for sparse identical props.
---
## 4. Voxel raycasting: place & break blocks
- **Use a grid-DDA voxel traversal (Amanatides & Woo), not `Raycaster.intersectObjects` against block meshes.** Step the ray cell-by-cell through the voxel grid: track `tMax`/`tDelta` per axis, advance to whichever axis boundary is nearest, and check the block at each visited cell until you hit a solid block or reach max distance. This is exact, cheap, and independent of how the mesh is built.
- **Break:** set the hit cell to empty; rebuild that chunk's mesh (and any neighbor chunk if the block was on a border).
- **Place:** place the new block in the cell **adjacent to the hit face** — you must track *which face* you crossed to enter the solid cell (the last step axis + direction gives the face normal). Place at `hitCell + faceNormal`. Reject placement if that cell is non-empty or overlaps the player's AABB (don't let the player entomb themselves).
- **Highlight** the targeted block/face (wireframe or overlay) so the player sees what they'll hit.
---
## 5. Collision, feel & world gen
- **Collision = AABB vs voxels:** resolve the player capsule/box against the blocks it overlaps, axis-separated (X, then Y, then Z), like a 3D version of tile collision. Sub-step fast movement to avoid tunneling through thin walls at high speed/low FPS.
- **First-person feel:** pointer lock, gravity + jump, step-up over 1-block ledges optional; block-break should have a short "mining" delay + crack overlay + particle burst for satisfaction; place/break sounds.
- **World gen:** simplex/Perlin noise for heightmaps (e.g. `simplex-noise`), layered (base terrain + caves via 3D noise) — keep it cheap and run in the worker with meshing.
- **Ambient occlusion** (darkening block corners) adds huge visual quality but complicates greedy meshing; add later.
---
## 6. Common bugs to avoid (checklist)
- **One mesh per block** → thousands of draw calls, instant death. Merge into one mesh per chunk.
- **No face culling** → 6× the geometry, most of it hidden. Cull faces against neighbors (including across chunk borders).
- **Rebuilding chunk meshes every frame** → rebuild only on edit; cache geometry.
- **Not disposing unloaded chunk geometry/materials/textures** → memory leak/crash while exploring (Three.js doesn't GC GPU resources).
- **Meshing on the main thread** → frame hitches when chunks stream; use Web Workers + transferable typed arrays.
- **Chunk-border faces wrong** (missing or double faces at seams) → meshing must see neighbor chunk blocks.
- **Using `intersectObjects` for block picking** → slow/fragile; use grid-DDA voxel traversal and track the entry face for placement.
- **Placing blocks on the wrong side / inside the player** → place at hitCell + face normal; reject if occupied or overlapping the player AABB.
- **Texture atlas bleeding** between tiles → padding or texture arrays, `NearestFilter`.
- **Collision tunneling at speed** → axis-separated AABB resolution + sub-stepping.
---
## Defaults to apply
1. **One merged mesh per chunk, never one mesh per block.** Store blocks in flat typed arrays; chunk the world (e.g. 16³). This is non-negotiable for browser performance.
2. **Always do face culling** (skip faces adjacent to solid blocks, across chunk borders too). Add **greedy meshing** (0fps.net algorithm) for scale; culled-per-face is OK for a small demo.
3. **Use a texture atlas / texture array so each chunk is one draw call.**
4. **Rebuild chunk meshes only on edit; stream and dispose chunks by distance; mesh + generate in Web Workers.**
5. **Break/place via grid-DDA voxel raycast (Amanatides & Woo), tracking the entered face** so placement goes on the correct adjacent cell; reject placements inside the player or occupied cells.
6. **Minimal scope:** flat/simple-noise world, FP movement + AABB voxel collision, place/break, a few atlas-textured block types. Get render + edit solid before AO, water, lighting, or mobs.
---
## Sources
- 0fps.net — "Meshing in a Minecraft Game" (greedy meshing, part 1): https://0fps.net/2012/06/30/meshing-in-a-minecraft-game/
- 0fps.net — "Meshing in a Minecraft Game" (part 2, texturing/AO trade-offs): https://0fps.net/2012/07/07/meshing-minecraft-part-2/
- Amanatides & Woo — "A Fast Voxel Traversal Algorithm for Ray Tracing" (grid-DDA block picking): http://www.cse.yorku.ca/~amana/research/grid.pdf
- Three.js — voxel/Minecraft-style tutorial (chunk geometry, raycast place/break): https://threejs.org/manual/#en/voxel-geometry
- Three.js — disposing objects (chunk unload): https://threejs.org/manual/#en/cleanup
- MDN — Web Workers (off-thread meshing/gen): https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
- "Let's make a voxel engine" community wiki (chunking patterns): https://sites.google.com/site/letsmakeavoxelengine/home
@@ -0,0 +1,139 @@
# Unified Input for Browser Games (keyboard + mouse + touch + Gamepad, action mapping, deadzones, buffering)
Consolidated from MDN (Gamepad API, Pointer Events, KeyboardEvent) and fighting/platformer input-design canon (see Sources). Focus: what an AI builder needs so games are **playable on every device with one code path**, controls are rebindable, and input feels responsive (buffering) rather than dropped.
---
## 1. Architecture: one action layer over all devices
**Never scatter `if (keys.KeyW)` gameplay checks through your code.** Build two layers:
1. **Raw device state** — updated from events (keyboard/pointer) and polling (gamepad).
2. **Abstract actions** — a fixed set like `moveX`, `moveY`, `aimX`, `jump`, `attack`, `pause`. Gameplay reads only actions. Any device can drive any action → keyboard, touch, and gamepad all "just work," and rebinding is trivial.
```js
const actions = { moveX:0, moveY:0, jump:false, attack:false /* ... */ };
```
Compute actions once per frame in an `updateInput()` called at the top of the loop, then gameplay consumes `actions` (and derived `justPressed` edges).
---
## 2. Keyboard: use `event.code`, track state, don't act on the event
- **Track pressed keys in a `Set`** on `keydown`/`keyup`; read the set in the loop. **Do NOT run movement/gameplay directly in the event handler** (event repeat rate ≠ frame rate → frame-dependent, laggy movement).
- **Use `event.code`** (physical key, e.g. `'KeyW'`, `'Space'`, `'ArrowLeft'`) not `event.key` (layout/locale-dependent) and not the **deprecated `keyCode`**. `event.code` keeps WASD in the same physical spot on AZERTY/QWERTZ.
- **`preventDefault()` for game keys** (Space, arrows) so the page doesn't scroll; but don't blanket-block everything (leave F5, devtools, tab-out).
- **Clear the key set on `blur`/`visibilitychange`** — otherwise a key held while tabbing out gets "stuck down" forever (very common bug).
- For text-entry vs gameplay, gate input by focus/mode.
```js
const keys = new Set();
addEventListener('keydown', e => { keys.add(e.code); if (GAME_KEYS.has(e.code)) e.preventDefault(); });
addEventListener('keyup', e => keys.delete(e.code));
addEventListener('blur', () => keys.clear());
```
---
## 3. Pointer Events: unify mouse + touch + pen
- **Use Pointer Events (`pointerdown/move/up/cancel`) instead of separate mouse + touch listeners.** One API covers mouse, touch, and stylus, with `pointerId` for multitouch and `pressure`/`pointerType`.
- Handle **`pointercancel`** (OS steals the touch) as a release — forgetting it strands buttons/joysticks "held."
- Set CSS **`touch-action: none`** on the canvas and `preventDefault()` to stop scroll/zoom/pull-to-refresh eating input.
- Convert client coords to canvas/world coords using `getBoundingClientRect()` and `devicePixelRatio`; don't assume clientX == canvas pixel.
- Mouse-look for FPS uses the **Pointer Lock API** (`requestPointerLock` on a gesture; read `movementX/Y`) — see the Three.js controls skill.
---
## 4. Touch controls (mobile)
- **Virtual joystick** for movement (e.g. **nipplejs**, static or dynamic) mapped to `moveX/moveY` normalized 1..1; **on-screen buttons** or a right-side aim/tap zone for actions. Split-screen: left = move, right = act/aim.
- **Minimum 44px hit targets** (Apple HIG). Give visual feedback on press.
- Support multitouch: track pointers by `pointerId` so moving the joystick doesn't cancel a jump button.
- Normalize touch → the same **actions** as keyboard/gamepad; apply with delta time.
---
## 5. Gamepad API: poll every frame, use standard mapping + deadzones
- **Poll `navigator.getGamepads()` every frame** in the loop. The `gamepadconnected`/`gamepaddisconnected` events only tell you a pad exists — **do not cache the `Gamepad` object**; it's a snapshot. Get fresh state each frame.
- A pad often only appears **after the user presses a button** ("waking"). Handle connect/disconnect gracefully mid-game.
- Prefer **`gamepad.mapping === 'standard'`** — browsers remap Xbox/PS/etc. to a consistent layout:
- Buttons: 0=A/Cross, 1=B/Circle, 2=X/Square, 3=Y/Triangle, 4/5=bumpers, 6/7=triggers (**use `.value` 01**, analog), 8=Back/Select, 9=Start, 10/11=stick presses, 1215=D-pad U/D/L/R.
- Axes (1..1): 0=LX, 1=LY (**1 is up**), 2=RX, 3=RY.
- Read digital with `buttons[i].pressed`, analog triggers with `buttons[i].value > threshold`.
- Non-standard pads (`mapping !== 'standard'`) have unpredictable indices — offer a rebinding screen as fallback.
- Haptics via `gamepad.vibrationActuator?.playEffect('dual-rumble', {...})` where supported (progressive enhancement).
### Deadzones (required for analog sticks)
Sticks never rest at exactly 0. Apply a deadzone or you get drift ("character walks by itself").
- **Radial deadzone** (correct for sticks — treat X/Y together, not per-axis) with re-normalization so full tilt still reaches magnitude 1:
```js
function radialDeadzone(x, y, dz = 0.15) {
const m = Math.hypot(x, y);
if (m < dz) return { x: 0, y: 0 };
const scale = ((m - dz) / (1 - dz)) / m; // re-normalize
return { x: x * scale, y: y * scale };
}
```
- Typical deadzone 0.10.25. Also deadzone trigger `.value`. Per-axis deadzones make diagonals feel wrong — prefer radial.
---
## 6. Action mapping & rebinding
- Store bindings as **data** (action → list of physical inputs): `{ jump: ['Space','KeyW', {pad:0}], attack: ['Mouse0', {pad:2}] }`. `updateInput()` resolves bindings into `actions`.
- **Rebinding UI:** capture the next input event, write it into the binding table, persist to localStorage (see `save-persistence.md`). Detect and warn on conflicts.
- Support **multiple simultaneous devices** and per-action multiple bindings (WASD *and* arrows *and* stick all drive `moveX`).
- Keep a **`justPressed` / `justReleased` edge set**: compare this frame's action booleans to last frame's for one-shot actions (jump, shoot, menu confirm). Reading level-triggered state for these causes repeat-fire bugs.
---
## 7. Input buffering (responsiveness / fairness)
- **Buffer discrete actions for a short window** (~100150ms / ~68 frames) so an input pressed slightly before it's actionable still fires. Essential for platformers and fighting games — makes controls feel "tight" instead of "eating" inputs.
- E.g. jump pressed 4 frames before landing → still jump on landing.
- **Coyote time:** allow jump for a few frames *after* walking off a ledge — pairs with jump buffering; both dramatically improve platformer feel.
- Store buffered inputs with a timestamp/frame stamp; consume when the action becomes valid, and expire after the window.
- **Poll gamepad and sample input at a fixed rate tied to your fixed-timestep** update for deterministic gameplay/netcode; buffering + fixed step is what makes combos/prediction reproducible.
---
## 8. Bug-prevention checklist
- **Movement in the keydown handler** → frame-rate-dependent, jerky; set flags, move in the loop with `dt`.
- **Using `event.key`/`keyCode`** → breaks on non-US layouts / deprecated; use `event.code`.
- **Stuck keys after tab-out** → clear key set on `blur`/`visibilitychange`.
- **Caching the `Gamepad` object** → stale input; call `getGamepads()` every frame.
- **No stick deadzone** → character drifts; apply radial deadzone with re-normalization.
- **Per-axis deadzone** → mushy/wrong diagonals; use radial.
- **Ignoring `pointercancel`** → stuck touch buttons/joysticks on mobile.
- **Page scrolls/zooms during play** → missing `touch-action:none` / `preventDefault`.
- **Repeat-firing one-shot actions** → reading level state instead of `justPressed` edges.
- **"Ate my jump" feel** → no input buffering / coyote time.
- **Gamepad never detected** → user hasn't pressed a button to wake it; handle connect event + prompt.
---
## Defaults to apply
- **Always generate the two-layer input system**: raw devices → normalized `actions`, with `justPressed`/`justReleased` edges. Gameplay reads only actions, so keyboard+mouse, touch (nipplejs + buttons), and gamepad all work from one code path.
- **Bake in the safety defaults**: `event.code`, clear keys on blur, poll gamepad each frame, radial deadzone (~0.15) with re-normalization, `touch-action:none`, handle `pointercancel`.
- **Include jump/input buffering (~120ms) + coyote time** in platformers/action templates by default — biggest feel win for cheap.
- **Make bindings data-driven and persisted** so a rebinding screen is a small add, and auto-detect the active device to show correct button prompts.
- **Semantic signs for move/steer/flight** are **not** defined here — open the
**`controls` skill** (`.grok/skills/controls/SKILL.md`) so `steer`/`roll`
mean player-left correctly. This file is plumbing; that skill is the sign
convention + mandatory A/D self-test.
---
## Sources
- MDN — Gamepad API: https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API ; Using the Gamepad API: https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API ; Implementing controls: https://developer.mozilla.org/en-US/docs/Games/Techniques/Controls_Gamepad_API
- MDN — `Navigator.getGamepads()`: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getGamepads ; `Gamepad` / `GamepadButton`: https://developer.mozilla.org/en-US/docs/Web/API/Gamepad
- MDN — Pointer Events: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events ; `touch-action`: https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
- MDN — `KeyboardEvent.code` + code values: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code ; https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
- MDN — Pointer Lock API: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API
- W3C — Gamepad spec (standard mapping): https://w3c.github.io/gamepad/#remapping
- Josh Sunshine / gamepad mapping deep dive: https://adamjones.me/blog/gamepad-mapping/
- nipplejs (virtual joystick): https://github.com/yoannmoinet/nipplejs
- Input buffering & coyote time (game feel): GDC/《Celeste》 & fighting-game input design write-ups, e.g. https://www.gamedeveloper.com/design/how-to-add-input-buffering-to-your-game and Celeste physics notes.
@@ -0,0 +1,277 @@
# Phaser 3 / 4 — Deep Engine Guide (scenes, physics, scale, tilemaps, pooling, architecture)
Phaser is the default choice for **2D browser games**. This file goes deep on the parts that actually break in generated games. It assumes the general loop/mobile/perf rules in `threejs-foundational.md` and does **not** repeat them.
> **Version note (verified):** Phaser **3** is the mature, ubiquitous line (3.90+). Phaser **4** shipped in 2025 with a rewritten WebGL renderer (internally "Beam") and optional **WebGPU** path, plus the core split into modular packages. Phaser 4 is *intentionally near-API-compatible with Phaser 3* — the Scene lifecycle, Arcade/Matter physics, Scale Manager, Loader and GameObject APIs described here carry over. Prefer Phaser 3 (3.90.x) for maximum stability/plugin compatibility today; use Phaser 4 when you want the faster renderer/WebGPU and can tolerate a younger ecosystem. When unsure, target v3 API surface — it runs on both.
---
## 1. Scene model — the thing to get right first
A Phaser game is a **stack of Scenes**, each an almost self-contained world (own display list, cameras, input, tweens, physics, clock). The `Phaser.Game` instance owns only truly global systems: **Renderer, Animation Manager, global Cache, Registry (global DataManager), Input Manager, Scene Manager, Sound Manager, TimeStep**. Everything else (`this.add`, `this.tweens`, `this.physics`, `this.input`, `this.cameras`) is **per-scene**. A tween created in Scene A is unrelated to one in Scene B.
### Lifecycle callbacks (exact order)
```js
class GameScene extends Phaser.Scene {
constructor() { super('game'); } // unique string key
init(data) {} // (1) reset per-run state HERE, receives data from start/launch
preload() {} // (2) queue asset loads only
create(data) {} // (3) build objects; assets from preload are now ready
update(time, delta){} // (4) every tick while RUNNING (delta in ms)
}
```
Status flow: `PENDING → INIT (booted) → START → LOADING → CREATING → RUNNING`. `RUNNING` can go to `PAUSED` (renders, no update) or `SLEEPING` (no update, no render), then back. `stop()``SHUTDOWN` (can restart). `remove()``DESTROYED` (gone).
### The scene stack pattern (best-practice architecture)
Run **multiple scenes at once**, layered bottom→top by config order:
- **Boot** — load the tiny assets needed for the loading screen (bar image, logo); set Scale/registry defaults; `this.scene.start('preload')`.
- **Preload** — load everything with a visible progress bar (`this.load.on('progress', v => …)`), then `start('menu')`.
- **Menu / Title** — UI, start button.
- **Game** — actual gameplay.
- **UI / HUD** — run **in parallel** over Game via `this.scene.launch('ui')`; keeps HUD stable while Game restarts. Communicate via events/registry, never direct references.
- **Pause / GameOver** — overlay scenes launched over Game.
### Scene control verbs (get these exactly right — a top bug source)
| Method | Effect on target | Effect on caller |
|---|---|---|
| `start('k')` | **stops** then starts k | **stops** caller |
| `launch('k')` | starts k (parallel) | caller keeps running |
| `switch('k')` | starts or **wakes** k | **sleeps** caller |
| `run('k')` | resume if paused / wake if sleeping / restart if running / else start | caller keeps running |
| `pause`/`resume` | freeze update, keep render | — |
| `sleep`/`wake` | freeze update + render | — |
| `stop` | shutdown | — |
Rules of thumb: gameplay you replay from scratch → **start/stop** or **sleep/wake**; a modal (pause menu, shop) over live gameplay → **pause/resume** or **launch** an overlay; menus you revisit → **sleep/wake** is easier to reason about than start/stop.
### The #1 Phaser correctness bug: state not reset on restart
Scenes are **booted once, started many times**. Module-level or constructor-set flags persist across restarts.
```js
// BROKEN: gameOver stays true after restart → instant game over
class S extends Phaser.Scene { constructor(){ super('s'); this.gameOver = false; } }
// CORRECT: reset run state in init()
class S extends Phaser.Scene {
constructor(){ super('s'); }
init(){ this.gameOver = false; this.score = 0; } // runs on every start
}
```
Related: arrays of destroyed game objects survive restart. **Clean up on SHUTDOWN**:
```js
this.events.once('shutdown', () => { this.enemies.length = 0; });
```
### Cleanup rule
On `shutdown`, Phaser auto-destroys the scene's display list, tweens, timers and input. But **external references, global timers, DOM listeners, EventEmitter subscriptions on cross-scene emitters, and pooled arrays are yours to clear**. A destroyed GameObject still registered on an emitter is a classic crash. Always `this.events.once('shutdown', cleanup)`.
---
## 2. Physics: Arcade vs Matter (choose deliberately)
Phaser ships two physics systems. **Pick Arcade unless you specifically need Matter** — mixing/over-choosing is a common performance and complexity mistake.
### Arcade Physics — AABB, fast, for platformers/shooters/arcade
- **Only axis-aligned bounding boxes (rectangles) and circles.** No rotation of bodies, no slopes, no polygons. Extremely fast; handles thousands of bodies.
- Two body types: **Dynamic** (`this.physics.add.sprite`) responds to velocity/gravity/collisions; **Static** (`this.physics.add.staticGroup`) never moves (platforms, walls) and is cheaper.
- Core calls: `body.setVelocity(x,y)`, `setGravityY`, `setBounce`, `setCollideWorldBounds(true)`, `setImmovable(true)`, `setAllowGravity(false)`.
- **Collide vs Overlap:**
- `this.physics.add.collider(a, b, cb)` — separates bodies (they block each other), optional callback.
- `this.physics.add.overlap(a, b, cb)` — detects overlap without separation (pickups, hitboxes, triggers).
- Both accept Groups, arrays, or single objects; add a `processCallback` (4th arg) to conditionally allow/deny a collision.
- Config: `physics: { default: 'arcade', arcade: { gravity: { y: 300 }, debug: true } }`. Turn on `debug` while building — it draws body outlines and velocity vectors and reveals 90% of "collision doesn't work" issues.
- Set body size explicitly for non-rectangular sprites: `sprite.body.setSize(w,h).setOffset(x,y)` or `setCircle(r)`.
- **Fast small objects tunnel through thin walls** (AABB, discrete step). Mitigate: thicker colliders, cap max velocity, or set world bounds; Arcade has limited continuous collision.
### Matter.js — full rigid-body, for physics-toys/ragdolls/complex shapes
- Real rigid-body dynamics: rotation, arbitrary convex/compound polygons, constraints/joints, springs, restitution, friction, sleeping bodies.
- Use when the *physics itself is the game* (Angry-Birds-like, stacking, contraptions, vehicles) or you need realistic collisions/rotation.
- Config: `physics: { default: 'matter', matter: { gravity: { y: 1 }, debug: true } }`.
- `this.matter.add.sprite(x,y,key,null,{ shape:'circle', restitution:0.6 })`. Use collision events: `this.matter.world.on('collisionstart', (e)=>{ for (const p of e.pairs){…} })`, plus **collision filters/categories** for what hits what.
- Heavier than Arcade; watch body counts on mobile. Enable body sleeping for idle stacks.
**Decision:** platformer, top-down shooter, endless runner, breakout, most casual games → **Arcade**. Physics sandbox, realistic stacking/rotation, joints → **Matter**. Don't reach for Matter just for "better collisions" — 90% of 2D games are Arcade games.
---
## 3. Scale Manager — responsive/mobile without stretching
Set once in game config. The two modes that matter:
```js
scale: {
mode: Phaser.Scale.FIT, // letterbox: keep aspect, fit inside parent
autoCenter: Phaser.Scale.CENTER_BOTH,
width: 800, height: 600, // your fixed design resolution
parent: 'game',
// min/max clamp the FIT scaling:
min: { width: 400, height: 300 }, max: { width: 1600, height: 1200 }
}
```
- **`FIT`** — canvas scales to fit the parent preserving aspect ratio; letterbox bars appear. Your game logic runs at a **fixed design resolution** (800×600 here) — simplest and most predictable. Best default for most games.
- **`RESIZE`** — canvas always matches parent size; **no fixed resolution**, game world dimensions change. You must handle the `resize` event and reposition/relayout UI yourself (`this.scale.on('resize', (gameSize)=>{…})`). Use for games that should truly fill the screen (strategy, infinite canvas) where you can reflow layout.
- `ENVELOP` — cover the parent (may crop). `WIDTH_CONTROLS_HEIGHT` / `HEIGHT_CONTROLS_WIDTH` — lock one axis.
- `NONE` — fixed, no scaling.
**Orientation:** listen `this.scale.on('orientationchange', o => …)` or check `this.scale.isPortrait`. For a landscape-only game, show a "rotate your device" overlay in portrait rather than fighting the layout. Combine with `viewport` meta + `touch-action: none` (see three.js file §7).
**Retina/DPR:** Phaser respects `resolution`/DPR via the renderer; for pixel-art keep `pixelArt: true` (sets nearest-neighbor + disables antialias) and `roundPixels: true` to avoid sub-pixel shimmer. Rendering at a modest internal resolution and letting FIT upscale is a legit perf win on mobile.
---
## 4. Sprites, atlases & animations
- **Always pack sprites into a texture atlas** (TexturePacker → JSON Hash/Array, or free tools). One atlas = one texture bind = fewer draw calls and no seams. `this.load.atlas('sheet','sheet.png','sheet.json')`, then `this.add.sprite(x,y,'sheet','frameName')`.
- Uniform grid frames → `this.load.spritesheet('run','run.png',{ frameWidth:32, frameHeight:32 })`.
- Animations are **global** (stored on the Animation Manager), defined once, reused by any sprite in any scene:
```js
this.anims.create({ key:'run', frames:this.anims.generateFrameNames('sheet',{prefix:'run_',start:0,end:7,zeroPad:2}), frameRate:12, repeat:-1 });
sprite.play('run');
```
- Batching: sprites sharing the **same texture** batch into few draw calls. Interleaving textures (sprite from atlas A, then B, then A) breaks the batch. Group same-texture sprites and set depth thoughtfully.
- Bitmap fonts (`load.bitmapFont`) render far cheaper than lots of dynamic `Text` objects (each `Text` is its own canvas texture — expensive to update every frame). For frequently-changing text prefer bitmap fonts or update sparingly.
---
## 5. Tilemaps
For levels, use **Tiled** (`.tmx`/exported JSON) rather than hand-placing sprites.
```js
// preload
this.load.image('tiles','tileset.png');
this.load.tilemapTiledJSON('map','level1.json');
// create
const map = this.make.tilemap({ key:'map' });
const tileset = map.addTilesetImage('tilesetNameInTiled','tiles');
const ground = map.createLayer('Ground', tileset, 0, 0);
ground.setCollisionByProperty({ collides:true }); // set per-tile in Tiled
this.physics.add.collider(player, ground);
// object layer for spawns/enemies:
const objs = map.getObjectLayer('Objects').objects;
```
- **Static layers** (`createLayer`) are fast (culled, batched). Dynamic tile edits use the same layer API (`putTileAt`, `removeTileAt`) — layers are dynamic in current Phaser.
- Set collision by **property** or by tile index/array; visualize with `layer.renderDebug(graphics)`.
- Cull off-screen tiles automatically via the camera; keep tile layers reasonably sized, split huge worlds into chunks.
- Use **object layers** for spawn points, triggers, and entity placement instead of hardcoding coordinates.
---
## 6. Object pooling with Groups (the mobile perf lever)
Creating/destroying bullets, enemies, particles every frame thrashes GC and stutters. **Pool them.**
```js
this.bullets = this.physics.add.group({
classType: Bullet, maxSize: 64, runChildUpdate: true
});
// fire:
const b = this.bullets.get(x, y); // reuse dead one or make new (up to maxSize)
if (!b) return; // pool exhausted → skip
b.enableBody(true, x, y, true, true); // reactivate + show
// on expire/off-screen: DON'T destroy — recycle:
b.disableBody(true, true); // deactivate + hide, returns to pool
```
- `get()` returns an **inactive** member (recycled) or creates a new one until `maxSize`; returns `null` when full — always null-check.
- `killAndHide(child)` / `disableBody(true,true)` recycles; avoid `destroy()` for pooled objects.
- `runChildUpdate:true` calls each active child's `preUpdate`/`update` — put per-object logic in the class.
- Only iterate **active** members: `this.bullets.getChildren()` and skip `!child.active`, or use `group.getMatching('active', true)`.
- Reuse tween/particle emitters too; destroy emitters and tweens on `SHUTDOWN`.
---
## 7. Input
- Keyboard: `this.cursors = this.input.keyboard.createCursorKeys()` (arrows + space/shift), or `this.keys = this.input.keyboard.addKeys('W,A,S,D')`. **Read state in `update` via `.isDown`**; use `Phaser.Input.Keyboard.JustDown(key)` for single-press actions. Don't drive movement off `keydown` events.
- Pointer/touch: `this.input.on('pointerdown', p => …)`; enable object interaction with `sprite.setInteractive()` then `sprite.on('pointerdown', …)`. Set explicit hit areas for irregular sprites.
- Unify keyboard + touch + **Gamepad API** (`this.input.gamepad`) into one input-state object your `update` reads, so control code doesn't branch per device.
- Drag: `this.input.setDraggable(sprite)` + `'drag'` event. For virtual joysticks on mobile, integrate nipplejs or the rex virtual joystick plugin and feed normalized -1..1 into your input state.
---
## 8. Cameras & tweens
- Each scene has a main camera (`this.cameras.main`). Follow the player: `camera.startFollow(player, true, 0.08, 0.08)` (lerp for smooth follow), set `camera.setBounds(0,0,mapW,mapH)` and `setZoom()`. `setDeadzone()` avoids jitter around the player.
- Effects for free polish: `camera.shake(200, 0.01)`, `camera.flash()`, `camera.fade()`. Multiple cameras enable split-screen / minimaps (`camera.ignore(objects)` to exclude).
- Tweens for juice: `this.tweens.add({ targets:sprite, scale:1.2, yoyo:true, duration:120, ease:'Sine.easeInOut' })`. **Always destroy long-lived/looping tweens on SHUTDOWN** or they reference dead objects.
---
## 9. Cross-scene communication (avoid spaghetti)
- **Registry** (global DataManager): `this.registry.set('score', 0)` / `get` — shared across all scenes; emits `changedata` events.
- **Scene events / a shared EventEmitter** ("EventsCenter"): create one `new Phaser.Events.EventEmitter()` in a module, import into scenes, emit/subscribe. Lets the UI scene react to Game scene events with **zero direct coupling**. Always `off()` listeners on shutdown.
- `this.scene.get('ui').events.emit(...)` works but couples scenes — prefer the shared emitter/registry.
- Pass one-shot data via `this.scene.start('game', { level: 3 })` → arrives in `init(data)` / `create(data)`.
---
## 10. Performance checklist (Phaser-specific)
- **Object pooling** for anything spawned repeatedly (bullets, enemies, particles, floating text).
- **Texture atlases** everywhere; group same-texture draws to keep batches intact.
- Turn **off physics debug** in production; only add bodies to objects that need them.
- Process **only active** objects; set `sprite.active=false`/`visible=false` for off-screen entities instead of updating them.
- Prefer **static** physics bodies / static tile layers for non-moving geometry.
- Minimize per-frame `Text` updates and `Graphics` re-draws (redrawing a Graphics every frame is expensive) — cache to a texture (`generateTexture`) or use bitmap fonts.
- Avoid per-frame allocations in `update`; reuse vectors/objects.
- Destroy tweens, timers (`this.time` events), and particle emitters on `SHUTDOWN`; clear pooled arrays.
- Lazy-load per-level assets in that level's preload; unload with `this.textures.remove` / `this.cache` when leaving big levels.
- Smaller canvas + FIT upscale on low-end mobile; consider `Phaser.CANVAS` fallback only if WebGL is unavailable (WebGL is default and much faster).
- Profile with the browser FPS meter and `game.loop.actualFps`; Phaser 4's WebGPU path reduces CPU draw-call overhead further.
---
## 11. Common Phaser pitfalls (checklist)
- Run-state not reset in `init()` → instant game-over / carried-over score after restart.
- Not cleaning arrays/emitter listeners on `shutdown` → crashes on destroyed objects after restart.
- Using `this.scene.start` when you meant `launch` (start stops the caller — kills your HUD).
- Movement/animation not scaled by `delta` (Phaser `update(time, delta)` gives delta in **milliseconds**).
- Physics body doesn't match sprite art → set `body.setSize/offset`; wrong/no collider group; forgetting `setCollideWorldBounds`.
- Fast objects tunneling through thin walls in Arcade → thicker walls / velocity cap.
- Text objects updated every frame tanking FPS → bitmap fonts / update on change only.
- Creating instead of pooling bullets/enemies → GC stutter on mobile.
- `RESIZE` scale mode without repositioning UI → misaligned HUD; use `FIT` unless you handle `resize`.
- Loading assets in `create` instead of `preload` → assets not ready / race conditions.
- Mixing Matter + Arcade needlessly, or choosing Matter when Arcade suffices → perf and complexity cost.
---
## Defaults to apply
- **Default to Phaser (v3 API) for any 2D game**; scaffold the **Boot → Preload(progress bar) → Menu → Game → parallel UI → Pause/GameOver** scene stack automatically.
- **Always put per-run state reset in `init()`** and register a `this.events.once('shutdown', cleanup)` in every gameplay scene — this alone eliminates the most common generated-game bug (broken restart).
- **Choose Arcade physics by default; only emit Matter when the physics is the game.** Encode the decision table above in the builder's engine-selection step.
- **Emit object pools (Groups with `maxSize` + recycle via `disableBody`) for bullets/enemies/particles** rather than create/destroy, and **texture atlases** for all sprites.
- Use **`Scale.FIT` + fixed design resolution + `CENTER_BOTH`** as the responsive default; add a rotate-device overlay for orientation-locked games.
- Load levels from **Tiled JSON tilemaps** with collision-by-property and object layers for spawns, instead of hardcoded coordinates.
- Decouple scenes via a **shared EventEmitter + Registry**, never direct scene references.
- Turn **physics `debug` on during generation/testing, off in the shipped build.**
---
## Sources
- Phaser official docs — Scenes (lifecycle, systems, control methods, restart pitfalls): https://docs.phaser.io/phaser/concepts/scenes
- Phaser docs — Arcade Physics: https://docs.phaser.io/phaser/concepts/physics/arcade ; Matter Physics: https://docs.phaser.io/phaser/concepts/physics/matter
- Phaser docs — Scale Manager: https://docs.phaser.io/phaser/concepts/scale-manager
- Phaser docs — Tilemaps: https://docs.phaser.io/phaser/concepts/tilemaps ; Loader: https://docs.phaser.io/phaser/concepts/loader
- Phaser docs — Groups & object pooling: https://docs.phaser.io/phaser/concepts/gameobjects/group ; Animations: https://docs.phaser.io/phaser/concepts/animations
- Phaser docs — Input (keyboard/pointer/gamepad): https://docs.phaser.io/phaser/concepts/input
- Phaser 4 announcement / release: https://phaser.io/news (Phaser 4 + Beam renderer / WebGPU) and https://github.com/phaserjs/phaser/releases
- API reference: https://newdocs.phaser.io/docs/latest
- franzeus.medium.com — "How I optimized my Phaser 3 action game" (pooling, atlases, active-only processing)
- joshmorony.com — Phaser scaling / responsive guides
@@ -0,0 +1,141 @@
# Procedural Generation (seeded RNG, noise, dungeons/mazes/terrain, wave function collapse)
Consolidated from Red Blob Games, the simplex-noise library, and WFC primary sources (see Sources). Focus: what an AI builder needs to generate **varied but reproducible** worlds without the classic bugs — non-deterministic output, disconnected dungeons, blobby/banded terrain.
---
## 1. Seeded RNG is the foundation — never use bare `Math.random()`
`Math.random()` is **not seedable**, so you can't reproduce a level, share a seed, debug a bad map, or sync generation across clients in multiplayer. **Always drive procedural generation from a seeded PRNG.**
**mulberry32** — the go-to small, fast, good-quality 32-bit seeded PRNG:
```js
function mulberry32(seed) {
return function () {
let t = (seed += 0x6D2B79F5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; // [0,1)
};
}
const rng = mulberry32(12345); // same seed → identical sequence forever
```
Rules:
- **Hash string seeds to an int first** (e.g. `xmur3`) so `"cave-42"` maps to a stable number.
- **Use separate RNG streams for independent systems** (terrain vs loot vs enemy placement) so changing one doesn't reshuffle the others (`mulberry32(seed)`, `mulberry32(seed ^ 0x9e3779b9)`, …).
- Add helpers: `randRange(a,b)=a+rng()*(b-a)`, `randInt`, `pick(arr)`, `shuffle` (FisherYates using `rng`).
- For **networked/deterministic** generation, both sides must run the *same* PRNG and consume it in the *same order* (deterministic multiplayer is out of scope on this deploy target). Order-of-consumption bugs are the #1 desync source.
- Alternatives: `alea`, PCG. mulberry32 is plenty for games.
---
## 2. Noise: value / Perlin / simplex (for terrain, textures, organic fields)
Random-per-cell is white noise (harsh, uncorrelated). **Coherent noise** produces smooth, natural-looking fields where nearby points are similar.
- **Value noise** — cheap, interpolate random lattice values. OK for simple stuff.
- **Perlin noise** — classic gradient noise; can show axis-aligned artifacts.
- **Simplex / OpenSimplex** — fewer directional artifacts, scales better to higher dimensions; preferred default. Use the **`simplex-noise` npm package**.
- **v4 API changed:** no more `new SimplexNoise()`. Use factory functions and pass your seeded PRNG:
```js
import { createNoise2D } from 'simplex-noise';
const noise2D = createNoise2D(mulberry32(12345)); // returns ~[-1, 1]
const v = noise2D(x * 0.01, y * 0.01); // scale coords = "frequency"
```
(Also `createNoise3D`/`createNoise4D`; passing a PRNG makes output reproducible — without one it uses `Math.random()` = non-reproducible.)
**Key techniques (from Red Blob Games "Making maps with noise"):**
- **Frequency:** multiply input coords by a scale; small scale = large smooth features.
- **Octaves / fBm:** sum several noise layers at increasing frequency and decreasing amplitude (`amplitude *= persistence(≈0.5)`, `frequency *= lacunarity(≈2)`) for natural detail.
- **Normalize** noise from [1,1] to [0,1] before thresholding.
- **Redistribution:** `elev = Math.pow(elev, k)` to bias toward valleys/plains vs peaks.
- **Island mask:** multiply by a radial falloff so edges are water.
- **Biomes:** sample **two independent noise fields** (elevation + moisture) and look up a Whittaker-style biome table — don't derive moisture from elevation (they'd correlate and look wrong).
---
## 3. Dungeon / room generation
Common approaches, pick per game:
- **Rooms + corridors (BSP or random placement):** place non-overlapping rooms, then connect. BSP recursively splits space and puts a room in each leaf; connect sibling leaves → guaranteed structure. Random placement: drop rooms, reject overlaps, connect with L-shaped/A* corridors.
- **Cellular automata caves:** fill grid randomly (~45% wall), run several smoothing passes (`cell = neighbors≥5 ? wall : floor`) → organic caverns. Then **flood-fill and keep only the largest connected region** (or carve tunnels to connect regions).
- **Drunkard's walk / random walk:** carve floors along a random walk for winding caves.
**Critical rule: guarantee connectivity.** After generation, **flood-fill from the entrance and verify every important tile (exit, key rooms, loot) is reachable.** Discard/regenerate or carve connectors otherwise. "Unreachable exit / locked-in player" is the #1 procgen bug. Also validate: min room count, spawn ≠ exit, no soft-locks.
---
## 4. Maze generation
- Grid where each cell has walls; carve passages.
- **Recursive backtracker (randomized DFS):** deep, winding mazes with long corridors — most common.
- **Randomized Prim's / Kruskal's:** more uniform, bushier mazes.
- All produce a "perfect maze" (exactly one path between any two cells). Add **loops** by knocking out extra walls if you want multiple routes.
- Use your seeded `rng` for neighbor choice so the maze is reproducible.
---
## 5. Terrain generation
- **Heightmap from fBm simplex** (see §2): sample noise per cell → elevation → color/mesh. Threshold into water/sand/grass/rock/snow bands.
- **Domain warping** (offset sample coords by another noise) for more natural, less "blobby" coastlines.
- For 3D: feed the heightmap into a plane geometry's vertex Y (Three.js `PlaneGeometry` + displace vertices) and `computeVertexNormals()`; consider chunking + LOD for large worlds.
- Keep generation **deterministic per chunk**: derive each chunk's local seed from `(worldSeed, chunkX, chunkY)` so infinite/streamed worlds are stable and reproducible.
---
## 6. Wave Function Collapse (WFC) basics
WFC is a constraint-solver that generates output where every local neighborhood matches examples/adjacency rules — great for tile maps, textures, and levels that must "fit together."
Algorithm (overlapping or simple-tiled):
1. Every cell starts in **superposition** (all tiles possible).
2. **Observe:** pick the lowest-entropy cell (fewest remaining options) and **collapse** it to one tile (weighted-random).
3. **Propagate:** remove now-impossible neighbors' options based on adjacency rules; repeat until stable.
4. Loop until all cells collapsed, or **backtrack/restart on contradiction** (a cell with zero options).
Rules & gotchas:
- Adjacency rules come from a sample image (overlapping model) or hand-authored tile edges (simple-tiled model).
- **Contradictions happen** — implement restart or backtracking; without it, generation hangs/throws.
- Weight tiles for aesthetics; use noise (from §2) to bias regional weights for large-scale structure, then WFC for coherent local detail.
- For JS: **mxgmn/WaveFunctionCollapse** (original), Boris the Brave's write-up + **DeBroglie**, or ndarray-based JS ports.
> Note: Red Blob Games does **not** have a WFC tutorial (a common false citation). Cite Maxim Gumin's repo and Boris the Brave instead. Red Blob Games *is* the canonical source for **noise maps** and **A\*/grids**.
---
## 7. Bug-prevention checklist
- **`Math.random()` for generation** → non-reproducible, unshareable, undebuggable, desyncs multiplayer; use a seeded PRNG.
- **String seed used directly** → NaN/garbage; hash to int (xmur3) first.
- **Shared single RNG stream across systems** → tweaking one system reshuffles all; use separate streams.
- **No connectivity check** → unreachable exits / trapped player; flood-fill validate & regenerate.
- **Forgetting to seed simplex-noise (v4)** → falls back to `Math.random()`, non-reproducible; pass your PRNG to `createNoise2D`.
- **Single-octave noise** → smooth but featureless; add fBm octaves.
- **Deriving moisture from elevation** → correlated, unrealistic biomes; use independent noise fields.
- **WFC with no contradiction handling** → infinite loop / crash; add restart/backtrack.
- **Per-frame regeneration or huge synchronous gen** → jank; generate in chunks / a web worker / on level load.
- **Non-deterministic chunk seeds** → seams/flicker in streamed worlds; derive chunk seed from `(worldSeed, cx, cy)`.
---
## Defaults to apply
- **Every generated procgen game gets a visible/optional seed** driven by **mulberry32** (with xmur3 for string seeds), plus `randRange/randInt/pick/shuffle` helpers and **separate RNG streams** per system. Reproducibility is free QA, shareable content, and multiplayer-safe.
- **Terrain/organic → seeded `simplex-noise` v4 (`createNoise2D(rng)`) with fBm octaves + redistribution + island mask + independent elevation/moisture** per Red Blob Games.
- **Dungeons → rooms+corridors or cellular-automata caves, ALWAYS followed by a flood-fill connectivity guarantee** (regenerate on failure). Ship this validation step by default — it prevents the worst procgen bug.
- **Offer WFC** for tile-based levels/textures (with contradiction restart), optionally seeded by noise for macro structure. Do heavy generation in a worker/on load, not per frame.
---
## Sources
- Red Blob Games — Making maps with noise functions: https://www.redblobgames.com/maps/terrain-from-noise/
- Red Blob Games — Noise (value/Perlin/simplex intro): https://www.redblobgames.com/articles/noise/introduction.html
- Red Blob Games — Procedural map/dungeon & grids index: https://www.redblobgames.com/
- simplex-noise (npm, v4 `createNoise2D` API): https://www.npmjs.com/package/simplex-noise , https://github.com/jwagner/simplex-noise.js
- mulberry32 / xmur3 seeded PRNGs (bryc's gist): https://github.com/bryc/code/blob/master/jshash/PRNGs.md
- Stefan Gustavson — Simplex noise demystified (PDF): https://weber.itn.liu.se/~stegu/simplexnoise/simplexnoise.pdf
- Ken Perlin — Improving Noise (SIGGRAPH 2002): https://mrl.cs.nyu.edu/~perlin/paper445.pdf
- Cellular-automata cave generation (RogueBasin): https://www.roguebasin.com/index.php/Cellular_Automata_Method_for_Generating_Random_Cave-Like_Levels
- BSP dungeon generation (RogueBasin): https://www.roguebasin.com/index.php/Basic_BSP_Dungeon_generation
- Maze generation algorithms — Jamis Buck ("Buckblog") & "Mazes for Programmers": https://weblog.jamisbuck.org/2011/2/7/maze-generation-algorithm-recap
- Wave Function Collapse — Maxim Gumin (original): https://github.com/mxgmn/WaveFunctionCollapse ; Boris the Brave, "WFC explained": https://www.boristhebrave.com/2020/04/13/wave-function-collapse-explained/
@@ -0,0 +1,108 @@
# Save & Persistence for Browser Games (localStorage vs IndexedDB, versioned saves, serialization, autosave)
Consolidated from MDN storage docs and the `idb` library (see Sources). Focus: what an AI builder needs so saves **survive updates, don't corrupt, and don't block the frame** — the usual bugs are lost progress on a schema change, quota errors, and jank from synchronous writes.
---
## 1. Pick the right storage
**`localStorage`** — simple key/value, **synchronous, string-only, ~5MB** per origin.
- Good for: small settings, high scores, current-level, a single small save blob, key bindings, volume.
- **Synchronous = it blocks the main thread.** Fine for occasional small writes; **do NOT write large/frequent blobs to it every frame** (causes hitches). Values must be strings → `JSON.stringify`/`parse`.
- `sessionStorage` = same API but cleared when the tab closes (use for transient state only).
**IndexedDB** — **asynchronous**, transactional, large (hundreds of MB to GB, quota-based), stores **structured data** (objects, arrays, `Blob`, `ArrayBuffer`, typed arrays via the structured clone algorithm — no manual JSON needed).
- Good for: big/complex saves, multiple save slots, replays, generated assets/level caches, offline data.
- The raw API is verbose/callbacky — **use the `idb` wrapper** (Jake Archibald) for a clean promise-based API. Don't hand-roll raw IndexedDB.
**Rule of thumb:** settings/scores/small single save → `localStorage`; anything large, multi-slot, binary, or frequently written → **IndexedDB (via `idb`)**. Cookies are the wrong tool for game saves (tiny, sent on every request).
**Cache API / service worker** is for caching the *game itself* (assets, offline PWA), not for player save data.
---
## 2. ALWAYS version your saves (the #1 lost-progress bug)
Your save schema *will* change between game versions. If you `JSON.parse` an old save into new code with no version handling, you get crashes or silent corruption and angry players who lost progress.
**Rule: every save carries a `version` number, and you write migration steps between versions.**
```js
const SAVE_VERSION = 3;
function migrate(save) {
let s = { ...save };
if (s.version === 1) { s.coins = s.gold ?? 0; delete s.gold; s.version = 2; }
if (s.version === 2) { s.settings = { ...defaults.settings, ...s.settings }; s.version = 3; }
return s; // now at SAVE_VERSION
}
function loadSave(raw) {
let save = raw ?? structuredClone(defaultSave);
if (save.version !== SAVE_VERSION) save = migrate(save);
return save;
}
```
- Migrations run **in sequence** (v1→v2→v3) so any old save upgrades.
- IndexedDB has its own **schema versioning** via the `open(name, version)` + `onupgradeneeded`/`idb`'s `upgrade` callback — use it to create/alter object stores. That's separate from your *data* version above; you often want both.
- **New fields:** merge over defaults (`{ ...defaults, ...loaded }`) so older saves gain new keys with sane values instead of `undefined`.
- Keep a **backup of the previous save** before overwriting, so a failed migration/corruption is recoverable.
---
## 3. Serialization: what to save
- **Save state, not behavior.** Serialize plain data (positions, inventory, flags, RNG seed + counters). Never try to serialize class instances/functions/DOM/engine objects directly.
- **Save the seed + progress**, not the whole generated world, for procgen games — regenerate deterministically on load (see procgen skill). Massively smaller and robust.
- Use `structuredClone` / IndexedDB for objects with `Map`/`Set`/`Date`/typed arrays; **plain `JSON` drops those** (Map→`{}`, Date→string, `undefined`/functions omitted, `NaN`/`Infinity``null`). If using JSON, convert them explicitly.
- **Wrap every load in try/catch.** Corrupt/partial JSON must fall back to defaults, not white-screen the game.
- Consider a **checksum** or at least shape-validation of loaded data before trusting it. For competitive/leaderboard games remember client saves are trivially editable — validate server-side, don't trust local high scores.
---
## 4. Autosave (do it safely)
- **Autosave on meaningful events** (level complete, checkpoint, item gained) and on a throttled timer — not every frame.
- **Debounce/throttle writes** (e.g. at most once every few seconds) to avoid thrashing storage and causing jank; coalesce rapid changes.
- **Save on `visibilitychange` (hidden) and `pagehide`/`beforeunload`** — mobile browsers kill backgrounded tabs without firing `beforeunload` reliably, so `visibilitychange → hidden` is the most reliable "player is leaving" signal. Do a final synchronous-ish flush there (localStorage) or a queued IndexedDB write.
- **Write atomically:** for IndexedDB use a transaction; for localStorage, build the full string then set it once (a half-written multi-key save = corruption). Keep the previous save until the new one is confirmed.
- Give visible feedback ("Saved") and expose manual save/load + multiple slots where appropriate.
---
## 5. Quota, availability & privacy gotchas
- **Storage can throw or be disabled:** private/incognito mode, quota exceeded (`QuotaExceededError`), or blocked third-party storage. **Feature-detect and wrap in try/catch**; degrade gracefully (in-memory only) rather than crashing.
- **Eviction:** browsers can clear storage under pressure ("best-effort" default). For important saves, request **`navigator.storage.persist()`** to reduce eviction risk, and check `navigator.storage.estimate()` for usage/quota.
- Storage is **per-origin**; a domain change loses saves. iOS Safari has historically been aggressive about clearing storage from rarely-visited sites — don't treat browser storage as permanent; offer export/import (download/upload a save file) for anything precious, and cloud save for accounts.
- **`localStorage` blocks the main thread** — keep values small; move big/async work to IndexedDB.
---
## 6. Bug-prevention checklist
- **No version field** → old saves crash new builds / silent data loss; version + sequential migrations.
- **Parsing without try/catch** → corrupt save white-screens the game; fall back to defaults.
- **Merging loaded save without defaults** → new fields are `undefined`; spread over defaults.
- **JSON round-trip of Map/Set/Date/typed arrays** → data silently lost; use structuredClone/IndexedDB or convert explicitly.
- **Frequent/large `localStorage.setItem`** → main-thread hitches; throttle + use IndexedDB for big data.
- **No save on tab hide** → mobile players lose progress; save on `visibilitychange`/`pagehide`.
- **Non-atomic multi-key writes** → partial/corrupt saves; write one blob / use a transaction, keep a backup.
- **Assuming storage always works** → crashes in private mode / over quota; feature-detect + try/catch + graceful degrade.
- **Trusting local high scores** → trivially cheatable; validate server-side for leaderboards.
- **Saving the whole procgen world** → bloated saves; save seed + progress and regenerate.
---
## Defaults to apply
- **Ship a tiny save module by default:** `save(state)` / `load()` with a **`version` field + sequential `migrate()`**, **try/catch → defaults**, and **merge-over-defaults**. This single pattern prevents the most damaging bug (progress lost on update).
- **Storage routing rule baked in:** settings/scores/small save → `localStorage`; large/multi-slot/binary/frequent → **IndexedDB via `idb`**.
- **Autosave defaults:** save on checkpoints + throttled timer + **`visibilitychange`/`pagehide`**, written atomically with a retained backup.
- **Persist the seed, not the world** for procgen; offer **export/import** for precious saves and request `storage.persist()`. Never trust local scores for leaderboards.
---
## Sources
- MDN — Web Storage API (`localStorage`/`sessionStorage`): https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
- MDN — IndexedDB API + Using IndexedDB (versioning/`onupgradeneeded`): https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API , https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
- MDN — Structured clone algorithm (what serializes): https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm ; `structuredClone()`: https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
- MDN — Storage quotas & eviction / `navigator.storage`: https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria , https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist
- MDN — `Document.visibilitychange` / Page Visibility API: https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API
- `idb` (promise-based IndexedDB wrapper, Jake Archibald): https://github.com/jakearchibald/idb
- web.dev — "Storage for the web" (choosing storage): https://web.dev/articles/storage-for-the-web
- localForage (localStorage-like API over IndexedDB): https://github.com/localForage/localForage
@@ -0,0 +1,148 @@
# Browser Game Best Practices — Three.js / Babylon.js / Phaser (controls, camera, orientation, loop, assets, perf)
Consolidated from official docs, well-regarded tutorials, and engine forums (see Sources at bottom). Focus: what an AI builder needs to get **browser games that feel right and don't have the usual bugs.**
---
## 1. Game loop & timing (the #1 correctness issue)
**Rules:**
- Drive the loop with **`renderer.setAnimationLoop(fn)`** in Three.js (internally uses `requestAnimationFrame`, plays nicely with WebXR) or the engine's built-in loop (Phaser `update(time, delta)`, Babylon `scene.onBeforeRenderObservable` / `engine.runRenderLoop`). Avoid `setTimeout`/`setInterval` and `Date.now()`.
- **Scale ALL movement/animation by delta time** (seconds since last frame) so speed is frame-rate independent (30fps laptop vs 144Hz monitor). e.g. `mesh.position.x += speed * delta`.
- **Cap delta** to avoid huge jumps after a backgrounded tab: `delta = Math.min(delta, 0.1)`.
- **Compute delta exactly once per frame** and reuse it everywhere.
- Three.js gotcha: `THREE.Clock.getDelta()` mutates state — calling it more than once per frame returns ~0 on later calls (very common bug). Newer `THREE.Timer` (Clock deprecated ~r183) has an explicit `.update()` so delta can be read multiple times safely.
- **For physics/gameplay stability use a fixed timestep + accumulator** ("Fix Your Timestep"): run `fixedUpdate(FIXED_STEP)` (e.g. 1/60) in a `while (accumulator >= FIXED_STEP)` loop, render at display rate, optionally interpolate. Variable delta is fine for purely visual demos.
- **Modularize**: give objects a `.update(delta)`/`.tick(delta)` method and iterate an "updatables" list; keep the top-level loop lean.
- **On-demand rendering**: if nothing is animating, stop the loop / render only on change to save battery (Three.js `setAnimationLoop(null)`; R3F `frameloop="demand"`).
**Reference pattern (Three.js, Timer + capped delta + fixed step):**
```js
const timer = new THREE.Timer();
let accumulator = 0; const FIXED = 1/60;
function animate() {
timer.update();
let delta = Math.min(timer.getDelta(), 0.25);
accumulator += delta;
while (accumulator >= FIXED) { fixedUpdate(FIXED); accumulator -= FIXED; } // physics/AI
updateVisuals(delta);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
```
---
## 2. First-person / WASD + mouse-look controls (Three.js)
The canonical reference is the official `misc_controls_pointerlock` example (https://threejs.org/examples/misc_controls_pointerlock.html).
**Key facts & rules:**
- **`PointerLockControls` only handles the Pointer Lock API + mouse-look (yaw/pitch). It does NOT include WASD movement — you implement keyboard + translation yourself.**
- Add **`controls.getObject()`** (the yaw container) to the scene, not the camera directly; the camera is a child for pitch.
- Track keys with **boolean state flags** set in `keydown`/`keyup` (support both `KeyW`/`ArrowUp`, etc.). Do NOT move on the keydown event itself.
- Move with **velocity + delta time** (acceleration + friction/damping), not a fixed distance per press — feels far better.
- Use **`controls.moveForward()` / `controls.moveRight()`** — they respect current yaw (local XZ plane). Do NOT modify `camera.rotation`/`camera.position` directly for horizontal movement.
- Pointer lock **requires a user gesture**: always show a "Click to play" blocker overlay; call `controls.lock()` on click; handle `'lock'`/`'unlock'` events and `pointerlockerror`; provide escape-to-unlock and a crosshair.
- **Avoid the deprecated `FirstPersonControls`** (behaves like fly controls).
- For real collisions/slopes/stairs, integrate a physics character controller: **Rapier.js `KinematicCharacterController`** (currently favored), or cannon-es (`PointerLockControlsCannon`), Ammo.js. Sync the body to `controls.getObject()`.
- Encapsulate as a `FirstPersonController` class with `update(delta)`, `lock()`, `dispose()`, and tunable `speed`/`jumpHeight`. Add sprint (Shift), crouch, head-bob for feel.
- R3F: use drei `<PointerLockControls />` + `useKeyboardControls` + `useFrame`.
**Other camera rigs:**
- **Orbit / product inspection:** Three.js `OrbitControls`; Babylon `ArcRotateCamera` (set `lower/upperRadiusLimit`, `lower/upperBetaLimit`, `inertia`, `panningSensibility`).
- **Third-person follow:** lerp camera toward a target offset behind the player each frame; `camera.lookAt(player)`; Babylon has `FollowCamera`.
- Keep the far plane / `camera.maxZ` (Babylon) as small as practical — improves culling, overdraw, and depth precision.
---
## 3. 3D orientation & coordinate conventions (a top source of "why is my model sideways/backwards" bugs)
- **Three.js is right-handed, +Y up:** +X right, +Y up, +Z toward the viewer. Default camera sits at +Z looking toward Z. (Differs from Unreal's Z-up, etc.) Changing global up (`Object3D.DefaultUp`) is discouraged — it affects helpers/grids.
- **Forward-axis gotcha:**
- Most `Object3D` (Mesh/Group): **local forward is +Z** `(0,0,1)`. Model/rotate so the "front" aligns with +Z.
- **Camera** (and some lights): forward is **local Z** `(0,0,-1)`. This is the classic "camera looks backwards" gotcha.
- Many primitives (`ConeGeometry`, `CylinderGeometry`) point along **+Y** by default → rotate before use, e.g. `geo.rotateX(Math.PI/2)` to make +Y → +Z (align an arrow/cone's tip with forward).
- **`object.lookAt(target)`** rotates in **world space** so the forward axis points at the target (uses `.up`, quaternions internally; does not move the object). For nested targets, first `target.getWorldPosition(v)`. To face a *direction* vector: `obj.lookAt(obj.position.clone().add(dir))`.
- Imported glTF/GLB models may need an initial rotation to match +Z-forward. glTF/Blender export is right-handed Y-up, consistent with Three.js.
- Debug with `AxesHelper` and `ArrowHelper` to visualize orientation quickly.
---
## 4. Performance (Three.js-centric, principles apply broadly)
**The #1 killer is draw calls** (`renderer.info.render.calls`). Target **<100/frame** for smooth 60fps on most hardware; 500+ stutters; mobile is stricter.
- **Share materials** aggressively (reuse one material instance across meshes).
- **Instancing (`InstancedMesh`)** for many *identical* objects (trees, crowds, bullets) → one draw call; update via `setMatrixAt` + `instanceMatrix.needsUpdate`. Note: default frustum culling applies to the whole instanced mesh.
- **`BatchedMesh`** (r156+) for *varied* geometries sharing one material, with per-object visibility + `perObjectFrustumCulled`.
- **Merge geometries** (`BufferGeometryUtils.mergeGeometries`) for fully static scenery sharing a material (loses per-object culling — one bounding volume).
- **Frustum culling** is automatic (`mesh.frustumCulled`); after modifying geometry call `computeBoundingBox()`/`computeBoundingSphere()`. Reduce `camera.far` where possible.
- **Dispose GPU resources manually** — Three.js does NOT GC them. On removing objects/switching levels: `geometry.dispose()`, `material.dispose()`, and dispose all textures. Watch `renderer.info.memory`. Use **object pooling** for particles/enemies instead of create/destroy churn.
- **Avoid allocations in the hot loop** (no `new Vector3()`/`new Matrix4()` per frame; reuse temporaries; Babylon has `TmpVectors` and `xxxToRef()`).
- **LOD** (`THREE.LOD`) — swap lower-poly at distance. Compress geometry (Draco) and textures (**KTX2/Basis**); prefer texture atlases.
- Limit lights; bake lighting/AO where possible; keep shadow maps small (and `RENDER_ONCE` refresh for static lights).
- Add **Stats.js**/FPS counter during dev; profile with Spector.js / engine inspector; profile on target devices.
**Babylon.js specifics:** prefer WebGPU w/ fallback + `powerPreference:"high-performance"`; `scene.performancePriority` (Intermediate/Aggressive); `scene.autoClear=false` when meshes cover viewport; `scene.skipPointerMovePicking=true` if no hover-picking; **thin instances** for static crowds; `mesh.freezeWorldMatrix()`, `material.freeze()`, `scene.freezeActiveMeshes()` for static content; `engine.setHardwareScalingLevel(2)` on low-end/mobile; `SceneOptimizer` to auto-degrade quality to hit a target FPS; keep `camera.maxZ` low. Camera "Behaviors" (Framing/Bouncing/AutoRotation) give polish for free.
**Phaser specifics:** heavy **object pooling** (Groups with `maxSize`, recycle via `active/visible=false`), process only active objects, texture atlases (Texture Packer), destroy tweens/emitters on `SHUTDOWN`, smaller canvas + CSS upscaling, lazy-load per-level assets.
---
## 5. Engine / stack choice
- **2D games → Phaser** (Phaser 3/4): scenes, Arcade/Matter physics, Scale Manager, huge ecosystem, great mobile support. Best default for 2D.
- **3D games → Three.js** (most control, largest community) or **Babylon.js** (batteries-included: inspector, physics, WebGPU, camera behaviors, SceneOptimizer). **PlayCanvas** if you want a full editor/engine.
- Physics: 2D → Arcade (simple) / Matter (rigid bodies); 3D → **Rapier.js** (fast, WASM, popular) / cannon-es / Ammo.js.
- Tooling: **Vite + TypeScript**; PWA (manifest + service worker) for installable/offline mobile.
**Phaser architecture (from best-practice write-ups):** one file per Scene extending `Phaser.Scene`; a scene stack of Boot → Preload (progress bar) → Menu → Game → parallel UI/HUD (`scene.launch`) → Pause/GameOver. Lifecycle: `init(data)``preload()``create(data)``update(time,delta)`; clean up on `SHUTDOWN`. Constants file for all string keys. Decouple scenes via an EventEmitter "EventsCenter" or `registry`, not direct references. Prefabs (custom GameObject subclasses) + FSM/ECS for complex logic instead of a giant `update()`.
---
## 6. Assets: generate vs. code vs. link
- **Generate real images** for sprites/textures/backgrounds/UI where the builder can (matches Lovable/v0 philosophy: no placeholder images in the final product). For pixel art request specific specs ("clean 32×32 side-view run cycle, 8 frames, sprite sheet, transparent background").
- **Don't hand-draw complex SVG/geometry** for illustrations or maps — use real assets or a library (echoes v0/Claude anti-slop rules).
- **Optimize for the browser:** compressed textures (KTX2/Basis/WebP), power-of-two, atlases, keep total download small (aim <2050MB). Draco/Meshopt for glTF.
- **Placeholder > bad attempt** (Claude): if a good asset can't be produced, use a clean placeholder rather than an ugly hand-rolled one.
- Set **`crossOrigin='anonymous'`** on images drawn to `<canvas>`/textures to avoid CORS taint (v0 rule).
- 3D model pipeline: glTF/GLB (Y-up, right-handed) with Draco/Meshopt/KTX2; verify forward-axis orientation on import.
---
## 7. Mobile / responsive / touch
- Distinguish the **canvas drawing buffer** (`canvas.width/height`) from **CSS display size**; account for `devicePixelRatio` to avoid blurry/tiny output. Rendering at a smaller internal resolution + CSS upscale is a valid perf win; `image-rendering: pixelated` for pixel art.
- Choose a **base design resolution** + aspect ratio; **letterbox (FIT)** to avoid stretching. Phaser: `scale: { mode: Phaser.Scale.FIT, autoCenter: CENTER_BOTH, width, height }` and handle the `resize` event; listen for `orientationchange`.
- Viewport meta: `width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no`; CSS `touch-action: none` and `preventDefault()` on touch to stop scroll/zoom.
- **Touch controls:** virtual joystick (e.g. **nipplejs**, `mode:'static'` or `'dynamic'`), split screen (left = move, right = actions/aim), normalize input to 1..1 and apply with delta time, visual feedback on active. **Minimum touch/hit target 44px** (Claude/Apple HIG). Unify keyboard + touch + Gamepad API into one input state.
- Test on real iOS Safari (audio unlock on gesture, WebGL context loss, touch coalescing) and low-end Android.
---
## 8. Common browser-game bugs to prevent (checklist)
- Movement tied to frame rate (no delta) → runs too fast/slow on different displays.
- `Clock.getDelta()` called multiple times per frame → things freeze/jitter.
- Model faces wrong way → forgot +Z-forward convention / camera Z; fix geometry rotation or `lookAt`.
- Pointer lock never engages → missing user-gesture click / blocker overlay.
- Memory leak / crash after level reload → geometries/materials/textures not disposed.
- Frame drops → too many draw calls (needs instancing/batching/atlases), per-frame allocations, uncompressed textures.
- Blurry or misaligned on mobile/retina → not handling devicePixelRatio / canvas resize.
- CORS-tainted canvas → missing `crossOrigin='anonymous'`.
- Physics tunneling/instability → variable timestep instead of fixed step.
- Audio doesn't play on mobile → not unlocked on first user gesture.
---
## Sources
- Three.js official: PointerLock example (https://threejs.org/examples/misc_controls_pointerlock.html), docs for `Timer`, `Object3D.lookAt`, `InstancedMesh`, `BatchedMesh` (https://threejs.org/docs/).
- "Discover three.js" — animation loop / Loop class (https://discoverthreejs.com/book/first-steps/animation-loop/).
- Three.js Discourse (Clock→Timer r183, setAnimationLoop vs RAF, coordinate-system threads) — https://discourse.threejs.org/.
- Three.js perf write-ups: utsubo.com "100 tips" (https://www.utsubo.com/blog/threejs-best-practices-100-tips), threejsroadmap.com draw calls.
- StackOverflow / Medium tutorials on PointerLock WASD and Rapier character controllers (https://medium.com/javascript-alliance/creating-a-first-person-character-controller-in-three-js-5d96534edfd8).
- Babylon.js: official optimization guide (https://doc.babylonjs.com/features/featuresDeepDive/scene/optimize_your_scene), SceneOptimizer, camera input docs; 2025 forum best-practices thread (https://forum.babylonjs.com/t/best-practices-for-optimizing-babylon-js-scenes-not-just-on-lower-end-devices/58688); Babylon 9.0 notes.
- Phaser: docs.phaser.io (Scenes, Arcade Physics), Phaser Discourse best-practices threads, franzeus.medium.com "How I optimized my Phaser 3 action game in 2025".
- Mobile/touch: nipplejs (https://github.com/yoannmoinet/nipplejs), joshmorony.com Phaser scaling guide.
- Fix Your Timestep (Gaffer On Games) — classic fixed-timestep reference.
+274
View File
@@ -0,0 +1,274 @@
---
name: controls
description: >
Player-facing input signs for browser games: WASD, vehicles, flight, FPS
mouse-look, and the #1 failure mode (inverted A/D). Mandatory control
self-tests and a tiny test interface so you can verify A turns left before
shipping. Load for ANY game with movement, steering, flying, driving, tanks,
boats, mechs, drones, planes, karts, third-person follow cams — not only
racing. Triggers on "controls", "WASD", "inverted", "steer", "flight",
"airplane", "kart", "vehicle", "yaw", "roll", "pitch", "mouse look", "A/D".
metadata:
short-description: "Control signs, inverted A/D fix, vehicle/flight maps, mandatory self-test"
user-invocable: false
---
# Controls (player-visible signs — do not ship inverted)
**Read this end-to-end before writing movement/steer/flight code** for any game
that uses WASD, arrows, a chase camera, or a flying craft. Do **not** skip this
and only open `racing-kart` or `fps` — those genre files assume you already
know these signs. Inverted A/D is the most common ship-blocker in vehicle and
flight demos.
Pair with **`building-games`** (loop, camera, 3D orientation) and
**`building-games/references/input.md`** (keydown state, gamepad, touch). This
skill owns **what left/right/up mean to the player** and **how you prove it**.
---
## 0. Hard rules (fail the build if broken)
1. **Player-visible left/right is law.** From a **chase / behind** camera while
the craft moves **forward**:
- **A / ←** → nose (or bank) turns **left on screen**
- **D / →** → nose (or bank) turns **right on screen**
2. **Never reuse FPS strafe as vehicle steer.** FPS “D → +right vector” is
**position** on the ground plane. Vehicle A/D is **yaw (or roll) rate**, not
a strafe offset. Mixing them is the #1 cause of inverted A/D.
3. **You must run a control self-test (§5) before saying done.** Screenshot-only
QA is not enough. If A turns right, **flip the steer/roll sign**, retest,
then ship — do not invent a new coordinate story.
---
## 1. Shared 3D basis (use this everywhere)
three.js: right-handed, **+Y up**, meshes face **+Z**, cameras look **Z**.
**Yaw-only heading on XZ** (ground vehicles, walkers, most arcade craft):
```
// yaw = 0 faces world Z; +yaw is CCW about +Y (nose moves toward X)
forward = (-sin(yaw), 0, -cos(yaw))
right = ( cos(yaw), 0, -sin(yaw)) // = normalize(cross(forward, worldUp))
```
With a chase cam **behind** the craft (camera near `position - forward * dist`):
| Player sees | World (this basis) | Input |
|-------------|--------------------|--------|
| Nose left | **+yaw** | **A / ←** must produce **+yaw** (or equivalent bank-left for planes) |
| Nose right | **yaw** | **D / →** must produce **yaw** |
If your basis differs, keep **one** consistent pair — but the **player-visible**
row above is mandatory.
---
## 2. Genre maps
### 2a. FPS / on-foot (strafe, not steer)
```
W = +forward, S = forward, D = +right, A = right // position, not yaw
mouse: yaw -= movementX * sens; pitch -= movementY * sens; clamp pitch
```
Body yaw for look; **movement uses yaw only** (do not apply pitch to walk).
### 2b. Ground / water vehicle (kart, bike, jetski, boat, tank, rover, snowmobile)
Arcade body with `heading`/`yaw` and forward `speed`:
```js
// Input (held keys → actions once per frame)
let steer = 0; // -1..+1, player-visible
if (keys.has('KeyA') || keys.has('ArrowLeft')) steer += 1; // LEFT
if (keys.has('KeyD') || keys.has('ArrowRight')) steer -= 1; // RIGHT
// Optional: steer = clamp(steer + gamepadX, -1, 1) with same sign convention
// Integrate (speedFactor ~ 0..1 from |speed|)
const reverse = speed >= 0 ? 1 : -1; // wheel-left still feels left in reverse
yaw += steer * turnRate * speedFactor * reverse * dt;
// Move along heading
const fx = -Math.sin(yaw), fz = -Math.cos(yaw);
position.x += fx * speed * dt;
position.z += fz * speed * dt;
```
**Canonical bug (do not copy):**
```js
// WRONG — this is what ships inverted A/D in the wild
if (KeyA) steer -= 1;
if (KeyD) steer += 1;
yaw += steer * turnRate * dt; // A → yaw → nose RIGHT on chase cam
```
If you already wrote `KeyA → steer--`, either **swap the key mapping** or
**negate once at integrate** (`yaw += -steer * …`) — then run §5. Do not flip
twice (keys + integrate + bank mesh).
### 2c. Fixed-wing flight (airplane, glider, RC plane)
| Input | Action | Player expectation |
|-------|--------|--------------------|
| **A / ←** | **Roll left** (aileron) | Left wing down / bank left |
| **D / →** | **Roll right** | Right wing down / bank right |
| **W / ↑** | Pitch (pick one scheme and label HUD) | Usually nose down *or* pull-up — be consistent |
| **S / ↓** | Opposite pitch | |
| **Q / E** | Yaw / rudder (optional) | Q left, E right |
- **A/D are not strafe** and not “ground steer with FPS signs.”
- Apply roll in the crafts **local forward axis** with a sign that matches
**bank left on A**. If the mesh banks the wrong way, flip **one** sign on the
roll apply (or on the A/D → roll mapping), not the whole basis.
- Coordinated turn: positive bank should produce a turn that matches the bank
direction under the chase/external cam.
### 2d. Heli / drone / 6DOF
Document the scheme on a start overlay. Minimum:
- Throttle/altitude on discrete keys must **not** stick “always up” after one
press (use held state or explicit up/down).
- Strafe/yaw: **A left, D right** in the crafts horizontal frame (player-visible).
### 2e. 2D side-scroller / platformer
**D / →** moves **right on screen**; **A / ←** moves **left**. Gravity only
inverted if the genre is explicitly upside-down.
---
## 3. Camera must agree
- Chase cam: `desired = craftPos + up*height + forward*(-followDist)`; lerp;
`lookAt(craft)`.
- Compute `forward` **once** for both movement and camera — do not rebuild with
opposite yaw sign in the camera path.
- Debug order: (1) keys register → (2) signs correct (§5) → (3) camera agrees.
---
## 4. Input plumbing (brief)
- Track keys with a `Set` on `keydown`/`keyup` using **`event.code`**; clear on
`blur` / `visibilitychange`. Move in the game loop with **dt**, not in the
key handler.
- Unify keyboard + touch + gamepad into **actions** (`throttle`, `steer`,
`pitch`, `roll`, …). See `building-games/references/input.md`.
- Touch: left stick = move/steer, right = actions; ≥44px targets.
---
## 5. Mandatory control self-test (before “done”)
Screenshot-only is **not** enough for any craft with A/D.
### 5a. Player-visible checklist (every vehicle / flight build)
While **moving forward** (speed > small epsilon), chase cam behind:
| Hold | Must observe within ~0.5s |
|------|---------------------------|
| **A** | Nose or bank moves **left** on screen |
| **D** | Nose or bank moves **right** on screen |
| **W** (ground) | Speed increases / moves along facing |
| **S** (ground) | Brakes or reverse (as designed) |
If A fails: flip steer/roll sign **once**, retest both A and D.
### 5b. Minimal test interface (implement this)
Expose a tiny hook so you (and automated QA) can prove signs without guessing
private closures:
```js
// e.g. src/game/controlsTest.ts — dev/QA only is fine
export type ControlsProbe = {
getYaw: () => number; // radians; or getHeading()
getSpeed: () => number;
/** Inject held actions instead of real keys; both stay applied until you
* change them, so §5c can hold a key across frames and clear at the end. */
setSteer?: (v: number) => void; // -1..1, same sign as production
setKeys?: (codes: string[]) => void; // held until the next call; `[]` clears
};
declare global {
interface Window {
__controlsTest?: ControlsProbe;
}
}
```
Wire `window.__controlsTest` from the game loop when `import.meta.env.DEV` or a
`?qa=1` flag is set.
### 5c. Automated smoke (run it)
Drive the §5b probe with the preinstalled **`agent-browser`** CLI — that is the
first move, not a hand-written script. **A thrown `eval` exits non-zero; a
merely falsy one does not**, so assert by throwing. Run it as one `batch` — one
CLI call, not one per verb — taking JSON on stdin; `--bail` stops at the first
failing step and exits non-zero.
```bash
agent-browser batch --bail <<'JSON' # find's label is case-sensitive: copy it from `snapshot -i`
[["open","http://127.0.0.1:8080/"],
["find","text","Start","click"],
["eval","if (!window.__controlsTest?.setKeys) throw Error('no §5b probe: add setKeys')"],
["eval","__controlsTest.setKeys(['KeyW'])"],
["wait","600"],
["eval","if (__controlsTest.getSpeed() <= 0.1) throw Error('W: no move')"],
["eval","(async () => { const t = __controlsTest, y0 = t.getYaw(); t.setKeys(['KeyW','KeyA']); await new Promise(r => setTimeout(r, 300)); t.setKeys(['KeyW']); const d = t.getYaw() - y0, w = Math.atan2(Math.sin(d), Math.cos(d)); if (w < -0.05) throw Error('A turns RIGHT — inverted, got ' + w.toFixed(2)); if (w <= 0.05) throw Error('A barely turned (' + w.toFixed(2) + ') — hold longer or check the sim is running'); return 'A ok' })()"],
["eval","__controlsTest.setKeys([])"],
["screenshot","/workspace/screenshots/controls.png"],
["close"]]
JSON
```
**Hold keys through the probe, not with `keydown`** — `agent-browser keydown`
does not reach §4's `Set`, so a correct game reads as broken. No `setKeys` on
your probe? Add it (§5b), or dispatch the key event yourself — the browser-QA
reference's **Keys** note has the mechanism and the form.
The hold sits **inside** one `eval`: spread across commands it lasts however
long they take, and the ±π wrap in `Math.atan2(Math.sin(d), Math.cos(d))`
reads a craft that turned more than π as one turning the other way. The IIFE is
what keeps the step re-runnable — a bare `const d = …` fails the second time
with "already declared" — and `async` is what lets the hold run in page time.
Repeat for **D** with `'KeyD'` and both comparisons mirrored (`w > 0.05` is
inverted, `w >= -0.05` is barely), single-quoted so the JSON needs no escapes;
for planes assert **roll**: A ⇒ bank left.
**`A turns RIGHT`** is the sign error: flip **one** sign and re-run.
**`A barely turned`** is not — the craft moved the right way, just less than
0.05 rad in 300 ms, which a boat or a heavy rover will do; raise the hold, or
check the sim is running, and flip nothing. Any other non-zero exit — no probe,
a wedged daemon — means the check never ran: read the message first. Read the
browser-QA reference `AGENTS.md` links before your first flow — verbs, argument
shapes and the fallback are there.
### 5d. What not to do
- Do **not** only test “D increases some internal variable.”
- Do **not** use FPS “D → +X when facing Z” as the vehicle pass condition.
- Do **not** flip mesh bank, camera, and steer all at once when fixing — change
**one** sign, retest.
---
## 6. Finish criteria (controls)
- [ ] Opened **this** skill before writing movement/steer/flight.
- [ ] Genre map chosen (§2) and start-screen / HUD labels match it.
- [ ] Chase-cam A/D player-visible test **passed** (§5a).
- [ ] `window.__controlsTest` (or equivalent) available in dev/QA and used once.
- [ ] No inverted bank mesh relative to roll/steer input.
- [ ] Keys are held-state + dt-scaled; no sticky thrust from a single Space tap
unless intentional and labeled.
If any box is unchecked, the game is **not** done.
+122
View File
@@ -0,0 +1,122 @@
---
name: design-ui
description: >
Design and build polished, non-generic UI for this TanStack Start + React +
Tailwind v4 + shadcn/Radix app. Use whenever you create or restyle any
interface surface — pages, landing pages, dashboards, forms, modals, nav, and
game overlays (start screens, HUD, menus). Covers design tokens, layout,
typography, color, spacing, motion, and the anti-"AI-slop" rules that keep
output from looking generic. Triggers on "design", "UI", "make it look good",
"polish", "landing page", "theme", "style", "redesign", "ugly", "clean up".
metadata:
short-description: "Polished, non-generic UI: tokens, layout, type, color, motion, anti-slop"
user-invocable: false
---
# Design & UI
Make interfaces that look intentional and premium, not template-generic. This is
the single biggest quality lever in the app builder. Apply it to **DOM / overlay
UI** — pages, chrome, HUD, menus, forms. (For a 3D game's gameplay canvas, see
the `building-games` skill; this skill governs the DOM UI layered over it.)
**Read `references/` for depth** (loaded on demand — don't inline it all):
- `references/refined-ui.md` — the full product-chrome/overlay design system.
- `references/typography.md` — type scale, pairing, rhythm.
- `references/surfaces.md` — elevation, borders, shadows, layering.
- `references/animations.md` — motion, easing, transitions.
- `references/performance.md` — keep UI smooth (60fps, no jank).
---
## 1. Design-system-first (do this before styling anything)
Define the system once, then compose from it. **Never** sprinkle ad-hoc values.
- **Tokens in CSS (Tailwind v4 is CSS-first).** Put the palette, radii, and fonts
in `src/styles.css` under `@theme` as CSS variables; consume them as Tailwind
utilities. One source of truth.
```css
@import "tailwindcss";
@theme {
--color-bg: #0b0b0f; --color-surface: #16161d;
--color-fg: #e7e7ea; --color-muted: #a0a0ab;
--color-primary: #14b8a6; --color-border: #26262f;
--radius: 0.75rem; --font-sans: "Inter", system-ui, sans-serif;
}
```
- **Use shadcn/ui components** (Radix primitives + `cva` variants + `tailwind-merge`)
for buttons, dialogs, dropdowns, inputs, etc. They're accessible and consistent.
Generate them into `src/components/ui`; style via tokens, not inline hex.
- **Tailwind v4 base fix — buttons need a pointer cursor.** v4's Preflight makes
`<button>` use `cursor: default`, which feels broken. Add this once in
`src/styles.css` so buttons/clickable roles show a pointer:
```css
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) { cursor: pointer; }
}
```
- **Ban ad-hoc styling:** no raw hex in JSX, no `text-white`/`bg-black` literals,
no arbitrary values like `p-[16px]` or `text-[13px]`. If you need a value,
it becomes a token or a scale step.
## 2. The quantified rubric (cheap rules that prevent "ugly")
- **≤ 35 colors total** (one primary + neutrals + at most one accent). No random
extra hues. Don't default to purple unless asked.
- **≤ 2 font families** (often one). Pair a display/heading with a body, or use one.
- **Line-height 1.41.6** for body; tighter for large headings.
- **When you override a background color, override the foreground/text color too**
(contrast must hold — check both light and dark).
- **Mobile-first**: design the ~390px layout first, then scale up. No horizontal
overflow; tap targets ≥ 44px.
- **Consistent spacing scale** (4/8-based). Generous whitespace beats cramming.
- **One accent, used sparingly** for primary actions — not everywhere.
## 3. Anti-AI-slop (the tells that make output look generic — avoid)
- **No gradient-blob filler**, no giant hero gradients as a substitute for content.
- **No emoji as icons** — use a real icon set (`lucide-react`).
- **No hand-drawn SVG** illustrations/maps/charts — use real libraries (`recharts`
for charts) or real generated images.
- **No placeholder images / lorem-gray boxes** in the final product — generate
real images or use real content; set `crossOrigin="anonymous"` on canvas images.
- **Avoid the overused-font look** (default system-only, or Comic Sans-tier picks).
- **Every element earns its place.** Cut decorative noise. Establish a system,
then vary with intent — not randomness.
- **Match the existing UI when editing** an app in place; don't introduce a second
visual language.
## 4. Layout & hierarchy
- Clear visual hierarchy: one primary action per view; size/weight/color express
importance. See `references/typography.md` and `references/surfaces.md`.
- Use real layout structure (grid/flex, container max-widths), not absolute-position
hacks. Align to a consistent grid.
- Empty states, loading states, and error states are part of the design — don't
ship blank/janky intermediate states.
## 5. Motion (subtle, purposeful)
- Short, eased transitions (150250ms) on hover/press/enter; respect
`prefers-reduced-motion`. Details in `references/animations.md`.
- Never animate layout in a way that causes jank; prefer transform/opacity.
## 6. Game overlays (when this pairs with `building-games`)
The gameplay canvas is owned by `building-games`. This skill styles the **DOM
overlay**: start/"click to play" screen, HUD, score, menus, pause, mobile
controls. Keep overlay readable over the canvas (backdrop, contrast), and keep it
out of the pointer-lock/gameplay input path.
---
## Finish checklist (before you call UI done)
- Tokens defined in `@theme`; no ad-hoc hex / arbitrary values in JSX.
- ≤ 5 colors, ≤ 2 fonts, consistent spacing scale.
- Contrast holds; foreground overridden wherever background is.
- Mobile (~390px) has no overflow; targets ≥ 44px.
- Real icons/images/charts — none of the anti-slop tells.
- Loading/empty/error states handled; motion subtle and reduced-motion-safe.
- Rendered and eyeballed in a browser (see AGENTS.md verification), not just curl.
@@ -0,0 +1,379 @@
# Animations
Interruptible animations, enter/exit transitions, and contextual icon animations.
## Interruptible Animations
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
### CSS Transitions vs. Keyframes
| | CSS Transitions | CSS Keyframe Animations |
| --- | --- | --- |
| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |
| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |
| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
| **Duration** | Adapts to remaining distance | Fixed regardless of state |
```css
/* Good — interruptible transition for a toggle */
.drawer {
transform: translateX(-100%);
transition: transform 200ms ease-out;
}
.drawer.open {
transform: translateX(0);
}
/* Clicking again mid-animation smoothly reverses — no jank */
```
```css
/* Bad — keyframe animation for interactive element */
.drawer.open {
animation: slideIn 200ms ease-out forwards;
}
/* Closing mid-animation snaps or restarts — feels broken */
```
**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
## Enter Animations: Split and Stagger
Don't animate a single large container. Break content into semantic chunks and animate each individually.
### Step by Step
1. **Split** into logical groups (title, description, buttons)
2. **Stagger** with ~100ms delay between groups
3. **For titles**, consider splitting into individual words with ~80ms stagger
4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect
### Code Example
```tsx
// Motion (Framer Motion) — staggered enter
function PageHeader() {
return (
<motion.div
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.1 } },
}}
>
<motion.h1
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
Welcome
</motion.h1>
<motion.p
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
A description of the page.
</motion.p>
<motion.div
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
<Button>Get started</Button>
</motion.div>
</motion.div>
);
}
```
### CSS-Only Stagger
```css
.stagger-item {
opacity: 0;
transform: translateY(12px);
filter: blur(4px);
animation: fadeInUp 400ms ease-out forwards;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 100ms; }
.stagger-item:nth-child(3) { animation-delay: 200ms; }
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
```
## Exit Animations
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
### Subtle Exit (Recommended)
```tsx
// Small fixed translateY — indicates direction without drama
<motion.div
exit={{
opacity: 0,
y: -12,
filter: "blur(4px)",
transition: { duration: 0.15, ease: "easeIn" },
}}
>
{content}
</motion.div>
```
### Full Exit (When Context Matters)
```tsx
// Slide fully out — use when spatial context is important
// (e.g., a card returning to a list, a drawer closing)
<motion.div
exit={{
opacity: 0,
x: "-100%",
transition: { duration: 0.2, ease: "easeIn" },
}}
>
{content}
</motion.div>
```
### Good vs. Bad
```css
/* Good — subtle exit */
.item-exit {
opacity: 0;
transform: translateY(-12px);
transition: opacity 150ms ease-in, transform 150ms ease-in;
}
/* Bad — dramatic exit that steals focus */
.item-exit {
opacity: 0;
transform: translateY(-100%) scale(0.5);
transition: all 400ms ease-in;
}
/* Bad — no exit animation at all (element just vanishes) */
.item-exit {
display: none;
}
```
**Key points:**
- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height
- Keep some directional movement to indicate where the element went
- Exit duration should be shorter than enter duration (150ms vs 300ms)
- Don't remove exit animations entirely — subtle motion preserves context
## Contextual Icon Animations
When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.
### Motion Example
```tsx
import { AnimatePresence, motion } from "motion/react";
function IconButton({ isActive, icon: Icon }) {
return (
<button>
<AnimatePresence mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<Icon />
</motion.span>
</AnimatePresence>
</button>
);
}
```
### CSS Transition Approach (No Motion)
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur.
```tsx
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
return (
<button>
<div className="relative">
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
"transition-[opacity,filter,scale] duration-300",
"ease-[cubic-bezier(0.2,0,0,1)]",
isActive
? "scale-100 opacity-100 blur-none"
: "scale-[0.25] opacity-0 blur-[4px]"
)}
>
<ActiveIcon />
</div>
<div
className={cn(
"transition-[opacity,filter,scale] duration-300",
"ease-[cubic-bezier(0.2,0,0,1)]",
isActive
? "scale-[0.25] opacity-0 blur-[4px]"
: "scale-100 opacity-100 blur-none"
)}
>
<InactiveIcon />
</div>
</div>
</button>
);
}
```
The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
### Choosing Between Motion and CSS
| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |
| --- | --- | --- |
| **Enter animation** | Yes | Yes |
| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |
| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |
| **When to use** | Project already uses `motion/react` | No motion dependency, or keeping bundle small |
**Rule:** Check the project's `package.json` for `motion` or `framer-motion`. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
### When to Animate Icons
| Animate | Don't animate |
| --- | --- |
| Icons that appear on hover (action buttons) | Static navigation icons |
| State change icons (play → pause, like → liked) | Decorative icons |
| Icons in contextual toolbars | Icons that are always visible |
| Loading/success state indicators | Icon labels (text next to icon) |
**Important:** Always use exactly these values for contextual icon animations — do not deviate:
- `scale`: `0.25``1` (never use `0.5` or `0.6`)
- `opacity`: `0``1`
- `filter`: `"blur(4px)"``"blur(0px)"`
- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }`**bounce must always be `0`**, never `0.1` or any other value
## Scale on Press
A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.
### CSS Example
```css
.button {
transition-property: scale;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.button:active {
scale: 0.96;
}
```
### Tailwind Example
```tsx
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
Click me
</button>
```
### Motion Example
```tsx
<motion.button whileTap={{ scale: 0.96 }}>
Click me
</motion.button>
```
### Static Prop Pattern
Extract the scale class into a variable and conditionally apply it based on a `static` prop:
```tsx
const tapScale = "active:not-disabled:scale-[0.96]";
function Button({ static: isStatic, className, children, ...props }) {
return (
<button
className={cn(
"transition-transform duration-150 ease-out",
!isStatic && tapScale,
className,
)}
{...props}
>
{children}
</button>
);
}
// Usage
<Button>Click me</Button> {/* scales on press */}
<Button static>Submit</Button> {/* no scale */}
```
## Skip Animation on Page Load
Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
### When It Works
```tsx
// Good — icon doesn't animate in on mount, only on state change
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
>
<Icon />
</motion.span>
</AnimatePresence>
```
Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
### When It Breaks
Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
```tsx
// Bad — initial={false} would skip the staggered page enter entirely
<AnimatePresence initial={false}>
<motion.div initial="hidden" animate="visible" variants={...}>
...
</motion.div>
</AnimatePresence>
```
Verify the component still looks right on a full page refresh before applying this.
@@ -0,0 +1,88 @@
# Performance
Transition specificity and GPU compositing hints.
## Transition Only What Changes
Never use `transition: all` or Tailwind's `transition` shorthand (which maps to `transition-property: all`). Always specify the exact properties that change.
### Why
- `transition: all` forces the browser to watch every property for changes
- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows)
- Prevents browser optimizations
### CSS Example
```css
/* Good — only transition what changes */
.button {
transition-property: scale, background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
/* Bad — transition everything */
.button {
transition: all 150ms ease-out;
}
```
### Tailwind
```tsx
// Good — explicit properties
<button className="transition-[scale,background-color] duration-150 ease-out">
// Bad — transition all
<button className="transition duration-150 ease-out">
```
### Tailwind `transition-transform` Note
`transition-transform` in Tailwind maps to `transition-property: transform, translate, scale, rotate` — it covers all transform-related properties, not just `transform`. Use this when you're only animating transforms. For multiple non-transform properties, use the bracket syntax: `transition-[scale,opacity,filter]`.
## Use `will-change` Sparingly
`will-change` hints the browser to pre-promote an element to its own GPU compositing layer. Without it, the browser promotes the element only when the animation starts — that one-time layer promotion can cause a micro-stutter on the first frame.
This particularly helps when an element is changing `scale`, `rotation`, or moving around with `transform`. For other properties, it doesn't help much — the browser can't composite them on the GPU anyway.
### Rules
```css
/* Good — specific property that benefits from GPU compositing */
.animated-card {
will-change: transform;
}
/* Good — multiple compositor-friendly properties */
.animated-card {
will-change: transform, opacity;
}
/* Bad — never use will-change: all */
.animated-card {
will-change: all;
}
/* Bad — properties that can't be GPU-composited anyway */
.animated-card {
will-change: background-color, padding;
}
```
### Useful Properties
| Property | GPU-compositable | Worth using `will-change` |
| --- | --- | --- |
| `transform` | Yes | Yes |
| `opacity` | Yes | Yes |
| `filter` (blur, brightness) | Yes | Yes |
| `clip-path` | Yes | Yes |
| `top`, `left`, `width`, `height` | No | No |
| `background`, `border`, `color` | No | No |
### When to Skip
Modern browsers are already good at optimizing on their own. Only add `will-change` when you notice first-frame stutter — Safari in particular benefits from it. Don't add it preemptively to every animated element; each extra compositing layer costs memory.
@@ -0,0 +1,384 @@
# Refined UI (product chrome and overlays)
Build interfaces that feel **intentional, calm, and expensive** — tight typography, restrained color, concentric radii, and fluid spacing. Prefer systems over one-off styles.
## Scope
**In scope**
- Start / title / menu surfaces layered over a game or app
- Overlay HUD and status chrome (score, health, timers, objectives)
- Pause, settings, win/lose, modals, sheets, toasts, on-screen controls
- Marketing-adjacent panels, cards, nav, footers, and form chrome in Build outputs
- Design tokens, type scales, radius scales, spacing, borders, shadows, motion on those surfaces
**Out of scope**
- Gameplay systems, entities, physics, combat, progression logic
- WebGL / canvas scenes, 3D art, materials, VFX, level design
- Decorative illustration that fights the product chrome
When a task spans gameplay and UI, apply this skill **only** to the overlay / DOM chrome.
## Non-negotiable anti-slop rules
These are hard fails. Fix before polish.
| Ban | Instead |
| --- | --- |
| Emoji in UI chrome, buttons, empty states, headings, or labels | Plain typography, sparse SVG icons (monochrome or single accent), or nothing |
| Purple, violet, magenta, or yellow / gold as brand or accent fills | Near-neutral surfaces; one restrained accent (cool blue-gray, ink, or soft white on dark) |
| Loud multi-stop gradients on backgrounds, buttons, or cards | Flat or near-flat surfaces; at most a **barely** perceptible linear wash (≤8% lightness delta) on large hero fields |
| Rainbow borders, neon glows, glassmorphism soup | Thin neutral borders (`1px`, low-contrast), soft single-layer shadows or hairline dividers |
| Default Inter-everything with no hierarchy | Deliberate display + body pairing and weight steps (see Typography) |
| Identical radius on parent and child | **Concentric** radii (see Border radius) |
| Random spacing and magic numbers | Tokenized spacing scale |
| Random bounce on every control | Short, tokenized motion (see Motion); reserve overshoot for rare micro moments (badge pop only) |
## Visual language
Aim for a **frontier / editorial** product feel:
- Dark or light **near-black / near-white** fields with quiet gray steps — not pastel candy, not neon cyberpunk
- Large, confident headlines with generous tracking control; body copy that breathes
- Abundant negative space; fewer elements with stronger hierarchy
- Surfaces read as **planes and panels**, not stickers
- Motion is subtle and physical (fade + slight translate / scale), never carnival
## Design tokens (encode once, reuse)
Define CSS variables (or equivalent) at the root of the UI layer. Prefer semantic names over raw hues.
```css
:root {
/* Surfaces — near-neutral, low chroma */
--bg: #0a0a0b;
--bg-elevated: #121214;
--bg-subtle: #1a1a1e;
--fg: #f4f4f5;
--fg-muted: #a1a1aa;
--fg-subtle: #71717a;
--border: color-mix(in oklab, var(--fg) 12%, transparent);
--border-strong: color-mix(in oklab, var(--fg) 22%, transparent);
/* Single restrained accent — cool, not purple/yellow */
--accent: #c8ccd4;
--accent-fg: #0a0a0b;
/* Radius scale (px) — use concentrically */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Spacing scale */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
/* Type */
--font-display: "Segoe UI", system-ui, -apple-system, sans-serif;
--font-body: "Segoe UI", system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--text-xs: clamp(0.75rem, 0.7rem + 0.2vw, 0.8125rem);
--text-sm: clamp(0.875rem, 0.82rem + 0.25vw, 0.9375rem);
--text-base: clamp(1rem, 0.95rem + 0.25vw, 1.0625rem);
--text-lg: clamp(1.125rem, 1rem + 0.5vw, 1.25rem);
--text-xl: clamp(1.5rem, 1.2rem + 1vw, 2rem);
--text-2xl: clamp(2rem, 1.4rem + 2vw, 3rem);
--text-3xl: clamp(2.5rem, 1.6rem + 3vw, 4rem);
--leading-tight: 1.1;
--leading-snug: 1.25;
--leading-normal: 1.5;
--tracking-tight: -0.02em;
--tracking-display: -0.03em;
/* Motion — durations */
--motion-stagger: 40ms;
--motion-micro: 80ms;
--motion-quick: 150ms;
--motion-fast: 250ms;
--motion-medium: 350ms;
--motion-slow: 400ms;
--motion-emphasis: 500ms;
/* Motion — easings */
--ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: ease-in-out;
--ease-linear: linear;
--ease-pop: cubic-bezier(0.34, 1.36, 0.64, 1);
/* Motion — distances / scales / blur */
--motion-y-micro: 4px;
--motion-y-small: 6px;
--motion-y-base: 8px;
--motion-y-medium: 12px;
--motion-scale-modal: 0.96;
--motion-scale-menu: 0.97;
--motion-scale-tooltip: 0.98;
--motion-blur-sm: 2px;
--motion-blur-md: 3px;
}
```
Light theme mirrors the same structure with inverted neutrals (warm-neutral off-whites, ink text). Keep chroma low.
**Rules**
- Never invent one-off hex in components if a token exists
- Borders use translucent mixes against `--fg` so they adapt on dark/light
- Accent is for **primary actions and focus**, not decorative fills on every card
## Color discipline
1. **Neutrals first.** 90%+ of UI area is background / elevated / text / border neutrals.
2. **One accent family.** Cool gray-blue or pure light-on-dark — never purple, violet, magenta, yellow, gold, or orange for brand chrome unless the user explicitly demands a brand palette.
3. **No emoji color.** Icons are stroke/fill monochrome or currentColor.
4. **Gradients only if nearly invisible** — e.g. vertical wash from `#0a0a0b` to `#101014`. No aurora, mesh, or “AI purple” backgrounds.
5. **Contrast.** Body text meets WCAG AA against its surface. Muted text is for secondary labels only.
6. **Semantic status** (success/warn/danger) may use restrained green / amber / red **only** on small badges/icons — not full panels.
## Border radius (concentric, mandatory)
Outer radius must equal **inner radius + padding** on that axis so curves nest optically.
```text
outerRadius = innerRadius + padding
```
Example: card with `padding: 16px` and inner control radius `8px` → card radius `24px` (`--radius-xl` if tokens map that way).
| Situation | Approach |
| --- | --- |
| Card containing buttons / inputs | Card uses larger radius; children use smaller from the scale |
| Nested panels | Each nesting level steps **down** one radius token |
| Pill controls | Full pill (`9999px`) only for small chips/buttons — not for large cards |
| Modals / sheets | `--radius-lg` or `--radius-xl` on the shell; inner sections `--radius-sm` / `--radius-md` |
Never set the same radius on parent and padded child.
## Typography pairing and weight
**Pairing**
- Prefer a **single family** with strong weight contrast for cohesion (system UI stacks are fine), **or** a restrained display + body pairing where display is used only for hero / title treatments
- Mono only for code, stats, and tabular IDs — not body paragraphs
**Weights (typical scale)**
| Role | Weight | Notes |
| --- | --- | --- |
| Hero / display | 500600 | Slightly tight tracking (`--tracking-display`) |
| Section titles | 500600 | Snug leading |
| Body | 400 | Normal leading |
| Labels / meta | 500 | Smaller size, muted color |
| Buttons | 500600 | Never ultra-black 900 on large type |
**Avoid**
- All-caps long sentences
- Pure black (`#000`) on pure white for large body blocks on marketing surfaces — prefer soft ink / soft paper
- More than **three** effective sizes on one screen chrome (excluding micro legal)
**Fluid type**
- Use `clamp()` tokens above so titles scale with viewport without jumping breakpoints
- Pair fluid type with fluid spacing (`clamp` or viewport-linked gaps) so rhythm stays consistent
## Layout and fluidness
- Prefer **fluid grids** (`minmax`, `auto-fit` / `auto-fill`) over rigid 12-column everything
- Max content width for readable text (~6075ch); hero type can break wider
- Consistent vertical rhythm from the spacing scale (stack with `--space-4` / `--space-5`, not 13px / 27px)
- Align to edges and columns; avoid “almost aligned” optical noise
- Safe areas on overlays: respect notches with `env(safe-area-inset-*)`
- HUD floats in corners/edges with padding from `--space-4`+; dont crowd the center except for intentional modals
## Surfaces, borders, depth
- Prefer **hairline borders** and slight elevation over heavy drop shadows
- One shadow recipe max, low opacity, large blur, minimal y-offset
- Dividers: `1px` using `--border`
- Panels on dark: elevated surface one step lighter than page bg, not a bright gray slab
- Avoid frosted blur stacks that obscure gameplay under overlays — if blurred, keep backdrop subtle and content readable
## Components (product chrome)
**Default to [shadcn/ui](https://ui.shadcn.com/).** Prefer official components from the registry over hand-rolled buttons, inputs, dialogs, menus, sheets, cards, tabs, badges, tooltips, selects, and form chrome. Docs and catalog: https://ui.shadcn.com/ and https://ui.shadcn.com/docs/components.
### Setup (when the project can use React + Tailwind)
```bash
npx shadcn@latest init -d --base radix
npx shadcn@latest add button card dialog input label select sheet tabs badge tooltip dropdown-menu separator skeleton alert-dialog
```
- Use non-interactive flags (`-d` / `--defaults`) so agents never block on prompts
- Prefer **new-york** style and theme tokens (`bg-background`, `text-foreground`, `border-border`, `text-muted-foreground`, `bg-card`, `ring-ring`) over ad-hoc hex
- Theme variables may be mapped to the token block above; do not invent a second parallel component kit
- Own the copied source under `components/ui` and restyle with tokens/anti-slop rules — still use the shadcn primitives and composition patterns
### Reach for shadcn first
| Need | Prefer (shadcn) | Notes |
| --- | --- | --- |
| Primary / secondary actions | `Button` | Variants for hierarchy; optional subtle `scale(0.98)` on press |
| Text fields | `Input` / `Textarea` + `Label` | Consistent height (~4044px), focus ring via theme |
| Confirm / pause / win-lose | `Dialog` or `AlertDialog` | Destructive confirms use `AlertDialog` |
| Settings / side panels | `Sheet` | Edge panels over custom drawers when possible |
| Overflow menus | `DropdownMenu` / `Popover` | Origin-aware from trigger |
| Grouped content | `Card` + `Separator` | Concentric radii with inner controls |
| Status / filters | `Badge` / `Tabs` | Quiet variants; no emoji |
| Hover hints | `Tooltip` | Delayed first show; instant siblings when appropriate |
| Loading placeholders | `Skeleton` | Then cross-fade to content |
Do **not** ship raw `<button>` / `<input>` / ad-hoc `div rounded-xl border p-6` shells when a shadcn component covers the need.
### When shadcn is not available
Plain HTML/CSS overlays (or non-React stacks) may implement the same patterns with tokens below. Match shadcn structure (button variants, dialog/sheet roles, card hierarchy) so a later migration is trivial.
### Buttons (token overlay / fallback)
- Primary: solid near-white or accent-on-dark, dark label; clear hover/active (opacity or slight brightness), `scale(0.98)` on active optional
- Secondary: transparent or elevated with `--border`
- No gradient fills, no emoji, no purple glow
### Inputs (token overlay / fallback)
- Height consistent (e.g. 4044px touch-friendly)
- Focus ring: 2px subtle accent or light ring, not rainbow
- Placeholder uses `--fg-subtle`
### Cards / menus (token overlay / fallback)
- Concentric radii; padding from spacing scale
- Title + muted description hierarchy
- Actions right-aligned or full-width stacked on small screens
### HUD chips
- Compact, high contrast, tabular numbers (`font-variant-numeric: tabular-nums`)
- Prefer `Badge` when on a shadcn stack; otherwise minimal chrome — no cartoon badges
## Motion and fluidity (production transitions)
Chrome motion should feel like a **small catalog of tuned recipes**, not ad-hoc timings. Prefer **CSS transitions** (interruptible) over keyframes unless the effect is a one-shot staged sequence (success check, error shake). Animate **inner pieces**, not a single wrapping box, when content swaps.
### Universal rules
- Enumerate properties — never `transition: all`
- Prefer `opacity` + `transform` (+ optional light `filter: blur` on crossfades)
- **Enter slower / exit quicker** when asymmetric (modal/menu close uses `--motion-quick`, open uses `--motion-fast`)
- Never enter from `scale(0)` — use `--motion-scale-modal``--motion-scale-tooltip` range
- Origin-aware surfaces (menus, popovers) scale from the **trigger**; centered modals stay center-origin
- Every recipe needs `@media (prefers-reduced-motion: reduce)` (opacity-only or instant)
- Keep durations on **CSS variables** so JS orchestration can `getComputedStyle` and stay in sync
- Replay animations with a reflow (`void el.offsetWidth`) between class remove and re-add when needed
- On close, use a closing class for the exit transition, then remove it after timeout — otherwise the next open jumps from the wrong scale
### Motion token usage (match by role, not by copying random ms)
| Role | Duration token | Easing | Notes |
| --- | --- | --- | --- |
| Per-item stagger | `--motion-stagger` (40ms) | — | Stacked text / list enter |
| Tooltip delay / shake segment | `--motion-micro` (80ms) | — | Small offsets |
| Menu/modal/tooltip **close**, in-place text swap | `--motion-quick` (150ms) | `--ease-smooth-out` or `--ease-out` | Snappy dismiss |
| Menu/modal **open**, icon swap, sliding tabs, page slide | `--motion-fast` (250ms) | `--ease-smooth-out` / `--ease-in-out` | Primary opens |
| Panel dismiss | `--motion-medium` (350ms) | `--ease-smooth-out` | Slightly heavier surfaces |
| Panel open, skeleton → content, clear dissolve | `--motion-slow` (400ms) | `--ease-smooth-out` / `--ease-in-out` | Contentful reveals |
| Badge appear, hero line reveal, rare success flourish | `--motion-emphasis` (500ms) | smooth-out or `--ease-pop` for badge only | Use sparingly on overlays |
**Blur on crossfade** (when two states overlap oddly): `--motion-blur-sm` (2px) for icon/text/number swaps; `--motion-blur-md` (3px) for page/panel slides. Keep blur off for simple opacity fades.
### Pattern → recipe (pick by what the user sees)
Match the **visible element + verb**, then implement with tokens above:
| If you see… | Use this transition recipe |
| --- | --- |
| Trigger + surface growing from it (settings, overflow) | **Origin-aware menu** — open `scale(--motion-scale-menu→1)` + opacity, `--motion-fast` / `--ease-smooth-out`; close faster (`--motion-quick`) with slightly higher scale floor (~0.99) |
| Centered pause / win / confirm dialog | **Modal** — open from `--motion-scale-modal`, `--motion-fast`; close `--motion-quick`; backdrop fades with opacity only |
| Sheet / drawer into an edge of the overlay | **Panel reveal** — translate on axis + optional `--motion-blur-sm` crossfade; open `--motion-slow`, close `--motion-medium` |
| List ↔ detail or step 1 ↔ step 2 in overlay | **Side-by-side page** — opposing translateX(`--motion-y-base`) + light blur; `--motion-fast` |
| Card / HUD cluster changing width or height | **Layout resize** — transition `width`/`height` or grid tracks with `--ease-smooth-out` and `--motion-fast` (avoid animating padding if possible) |
| Score / timer / counter updating | **Number pop-in** — per-digit re-enter with `--motion-y-micro` + `--motion-blur-sm`; stagger `--motion-stagger`; `tabular-nums` |
| Label / status text changing in one slot | **Text state swap** — outgoing slides/fades one way, incoming the other, light blur; `--motion-quick` |
| Two icons in one control (play/pause, mute) | **Icon swap** — cross-fade + scale + `--motion-blur-sm`; `--motion-fast` / `--ease-in-out`; keep both in DOM during swap |
| Small notification dot on a control | **Badge** — short diagonal/offset enter; optional `--ease-pop` once — not on every hover |
| Horizontal chips / avatar stack hover | **Distance falloff lift** — neighbors lift less; bouncy ease **only on mouseleave return**, set timing in JS before writing transform vars |
| Invalid field / failed action | **Error shake** — short segmented translateX with cubic segments; separate “error styling” class from “shake” class so shake can replay |
| Search/filter clear | **Clear dissolve** — control exits with motion; optional per-word streak; `--motion-slow` |
| Placeholder → loaded overlay content | **Skeleton reveal** — pulse with linear, then cross-fade + light blur to real content; `--motion-slow` |
| Loading / “thinking” status line | **Shimmer text** — masked highlight sweep, **linear**, loop; pure CSS; muted foreground |
| Segmented control / filter tabs | **Sliding pill** — move highlight with `transform` + width; first paint and resize with `transition: none` then restore |
| Hover hint on icon/control | **Tooltip** — delayed fade+scale in (`--motion-scale-tooltip`, `--motion-quick` / `--ease-out`); **instant or near-instant out**; subsequent siblings can skip delay (`data-instant`) |
| Title + subtitle entering a start screen | **Staggered text reveal** — blurred rise `--motion-y-medium`, stagger `--motion-stagger``--motion-micro`; quiet fade on exit |
**Tie-breakers:** prefer lower overhead (resize over panel, menu over modal, icon swap + checkmark over a full celebration modal). Dont stack three recipes on one interaction.
### Overlay-specific guidance
- Pause menus open often — favor **modal/menu** recipes with **quick close**, not multi-second cinematic intros
- HUD counters use **number pop-in**, not full card animations every tick
- Start-screen copy uses **staggered text reveal** once; respect reduced motion
- Never animate the WebGL canvas with these recipes — only DOM overlay nodes
### Anti-patterns
| Avoid | Prefer |
| --- | --- |
| `transition: all` | Explicit `opacity, transform` (and blur only when needed) |
| One duration for open and close | Asymmetric open/close tokens |
| Keyframes for hover toggles | CSS transitions (interruptible) |
| Animating the outer page wrapper for a badge | Animate the badge/dot only |
| Hardcoded ms in JS timeouts that drift from CSS | Read `--motion-*` from computed styles |
| Success stroke `stroke-dasharray` guesses | `path.getTotalLength()` (+1) for your path |
| Mixing error styling + shake in one class | Orthogonal classes so shake can reflow-replay |
## Content tone in the UI
- Labels are **short, plain language**
- No emoji, no “✨ magic” marketing fluff in chrome
- Prefer verbs for actions: Continue, Resume, Settings, Quit
## Implementation checklist
Before calling UI done:
- [ ] No emoji in any chrome string or icon slot
- [ ] No purple / yellow / gold accent system
- [ ] No loud gradients on backgrounds or buttons
- [ ] Components default to shadcn/ui (https://ui.shadcn.com/) when React+Tailwind is available
- [ ] Tokens used for color, space, radius, type, motion
- [ ] Nested radii obey `outer = inner + padding`
- [ ] Clear type hierarchy (size + weight + color), ≤3 primary sizes
- [ ] Fluid type/spacing where layouts scale
- [ ] Tabular nums on live stats
- [ ] Focus visible; contrast AA for text
- [ ] Motion uses token durations/easings and a matching recipe (menu, modal, number pop-in, etc.)
- [ ] Open/close asymmetric where appropriate; no `transition: all`; reduced-motion guarded
- [ ] Gameplay / canvas untouched by these styles
## Review format
When reviewing UI against this skill, use a markdown table:
| Before | After | Why |
| --- | --- | --- |
| Purple gradient CTA | Flat near-white primary on dark | Removes slop accent; quieter hierarchy |
| Card and button both `12px` radius with 16px pad | Card `28px`, button `12px` | Concentric radii |
| Title with emoji | Title plain, weight 600 | No emoji in chrome |
| `transition: all 300ms` on menu | `opacity/transform` with `--motion-fast` open / `--motion-quick` close, `--ease-smooth-out` | Tokenized, asymmetric, interruptible |
| Score textContent swap with no motion | Per-digit pop-in, `--motion-y-micro`, tabular-nums | Matches counter recipe |
| Modal scales from `0` | From `--motion-scale-modal` (0.96) | Avoid zero-scale pop-in |
@@ -0,0 +1,247 @@
# Surfaces
Border radius, optical alignment, shadows, and image outlines.
## Concentric Border Radius
When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:
```
outerRadius = innerRadius + padding
```
This rule is most useful when nested surfaces are close together. If padding is larger than `24px`, treat the layers as separate surfaces and choose each radius independently instead of forcing strict concentric math.
### Example
```css
/* Good — concentric radii */
.card {
border-radius: 20px; /* 12 + 8 */
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
/* Bad — same radius on both */
.card {
border-radius: 12px;
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
```
### Tailwind Example
```tsx
// Good — outer radius accounts for padding
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
...
</div>
</div>
// Bad — same radius on both
<div className="rounded-xl p-2">
<div className="rounded-xl"> {/* same radius, looks off */}
...
</div>
</div>
```
Mismatched border radii on nested elements is one of the most common things that makes interfaces feel off. Always calculate concentrically.
## Optical Alignment
When geometric centering looks off, align optically instead.
### Buttons with Text + Icon
Use slightly less padding on the icon side to make the button feel balanced. A reliable rule of thumb is:
`icon-side padding = text-side padding - 2px`.
```css
/* Good — less padding on icon side */
.button-with-icon {
padding-left: 16px;
padding-right: 14px; /* icon side = text side - 2px */
}
/* Bad — equal padding looks like icon is pushed too far right */
.button-with-icon {
padding: 0 16px;
}
```
```tsx
// Tailwind
<button className="pl-4 pr-3.5 flex items-center gap-2">
<span>Continue</span>
<ArrowRightIcon />
</button>
```
### Play Button Triangles
Play icons are triangular and their geometric center is not their visual center. Shift slightly right:
```css
/* Good — optically centered */
.play-button svg {
margin-left: 2px; /* shift right to account for triangle shape */
}
/* Bad — geometrically centered but looks off */
.play-button svg {
/* no adjustment */
}
```
### Asymmetric Icons (Stars, Arrows, Carets)
Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code.
```tsx
// Best — fix in the SVG itself
// Adjust the viewBox or path to visually center the icon
// Fallback — adjust with margin
<span className="ml-px">
<StarIcon />
</span>
```
## Shadows Instead of Borders
For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for.
**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders.
### Shadow as Border (Light Mode)
The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth:
```css
:root {
--shadow-border:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
--shadow-border-hover:
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
}
```
### Shadow as Border (Dark Mode)
In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds:
```css
/* Dark mode — adapt to whatever setup the project uses
(prefers-color-scheme, class, data attribute, etc.) */
--shadow-border: 0 0 0 1px rgba(255, 255, 255, 0.08);
--shadow-border-hover: 0 0 0 1px rgba(255, 255, 255, 0.13);
```
### Usage with Hover Transition
Apply the variable and add `transition-[box-shadow]` for a smooth hover:
```css
.card {
box-shadow: var(--shadow-border);
transition-property: box-shadow;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.card:hover {
box-shadow: var(--shadow-border-hover);
}
```
### When to Use Shadows vs. Borders
| Use shadows | Use borders |
| --- | --- |
| Cards, containers with depth | Dividers between list items |
| Buttons with bordered styles | Table cell boundaries |
| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) |
| Elements on varied backgrounds | Hairline separators in dense UI |
| Hover/focus states for lift effect | |
## Image Outlines
Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows.
### Light Mode
```css
img {
outline: 1px solid rgba(0, 0, 0, 0.1);
outline-offset: -1px; /* inset so it doesn't add to layout */
}
```
### Dark Mode
```css
img {
outline: 1px solid rgba(255, 255, 255, 0.1);
outline-offset: -1px;
}
```
### Tailwind with Dark Mode
```tsx
<img
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
src={src}
alt={alt}
/>
```
**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size.
## Minimum Hit Area
Interactive elements should have a minimum hit area of 44×44px (WCAG) or at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element.
### CSS Example
```css
/* Small checkbox with expanded hit area */
.checkbox {
position: relative;
width: 20px;
height: 20px;
}
.checkbox::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
}
```
### Tailwind Example
```tsx
<button className="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2">
<CheckIcon />
</button>
```
### Collision Rule
If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.
@@ -0,0 +1,123 @@
# Typography
Typography rendering details that make interfaces feel better.
## Text Wrapping
### text-wrap: balance
Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text.
```css
/* Good — even line lengths on short text */
h1, h2, h3 {
text-wrap: balance;
}
```
```css
/* Bad — default wrapping leaves orphans */
h1 {
/* no text-wrap rule → "Read our
blog" instead of balanced lines */
}
```
```css
/* Bad — balance on long paragraphs (silently ignored, wastes intent) */
.article-body p {
text-wrap: balance;
}
```
**Tailwind:** `text-balance`
### text-wrap: pretty
Optimizes the last line to avoid orphans using a slower algorithm that favors better typography over performance. Unlike `balance`, it works on longer text — use this for body copy where you want to minimize orphans without the 6-line limit.
```css
p {
text-wrap: pretty;
}
```
### When to Use Which
| Scenario | Use |
| --- | --- |
| Headings, titles, short text (≤6 lines) | `text-wrap: balance` |
| Body paragraphs, descriptions | `text-wrap: pretty` |
| Code blocks, pre-formatted text | Neither — leave default |
## Font Smoothing (macOS)
On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner.
```css
/* CSS */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
```
```tsx
// Tailwind — apply to root layout
<html className="antialiased">
```
### Good vs. Bad
```css
/* Good — applied once at the root */
html {
-webkit-font-smoothing: antialiased;
}
/* Bad — applied per-element, inconsistent */
.heading {
-webkit-font-smoothing: antialiased;
}
.body {
/* no smoothing → heavier than heading */
}
```
**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally.
## Tabular Numbers
When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change.
```css
/* CSS */
.counter {
font-variant-numeric: tabular-nums;
}
```
```tsx
// Tailwind
<span className="tabular-nums">{count}</span>
```
### When to Use
| Use tabular-nums | Don't use tabular-nums |
| --- | --- |
| Counters and timers | Static display numbers |
| Prices that update | Decorative large numbers |
| Table columns with numbers | Phone numbers, zip codes |
| Animated number transitions | Version numbers (v2.1.0) |
| Scoreboards, dashboards | |
### Caveat
Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font.
```css
/* With Inter font:
Default: 1234 → proportional, "1" is narrow
Tabular: 1234 → all digits equal width, "1" centered */
```
@@ -0,0 +1,84 @@
---
name: game-animation-frames
description: >
Deep guide for game ANIMATION assets: motion cycles, action keyframes,
effect sequences, and animation sprite sheets — built around a
video-first pipeline. In this app-builder sandbox, execute via the
video2dsprite / generate2dsprite skills (magenta + scripts), not ad-hoc
ffmpeg. Use whenever generating anything that moves: walk/run cycles,
attacks, idles, FX, flags, fire, animation sheets. Complements
game-asset-core.
metadata:
short-description: "Video-first animation frames that actually cycle"
user-invocable: false
---
# Animation Frames — video-first
The image generator draws poses; the VIDEO generator understands motion —
leg alternation, arc continuity, cloth and fire dynamics come free because
video must animate them. So don't ask the image model to imagine
mid-motion poses: animate the base and harvest real frames.
## App-builder execution (read first)
This skill is **doctrine** (motion laws, loop QC, when to use video). In the
app-builder sandbox, **do not** run a freeform `ffmpeg` harvest or invent a
random keyable `#hex` background.
| Step | Do this |
| --- | --- |
| Production sprites / fixed grids | **`generate2dsprite`** — solid **`#FF00FF`** magenta sheets + chroma scripts |
| Denser locomotion from video | **`video2dsprite`** — base still on **`#FF00FF`** → `imagine_image_to_video` → skill scripts (ffmpeg + chroma) |
| Keyable background | Always **`#FF00FF`** when using either pipeline (required for chroma) |
| This skill | Loop / flip-test / motion laws below — apply after the pipeline runs |
Open `.grok/skills/video2dsprite/SKILL.md` or `.grok/skills/generate2dsprite/SKILL.md`
and follow their workflows for generation and postprocess. Then apply the
laws and flip test here before shipping frames into the game.
## Default pipeline (intent)
1. **Base frame.** Subject in neutral/starting pose, full style words, side /
game-appropriate view, **solid `#FF00FF` background** (app-builder chroma
key). game-asset-core defaults apply.
2. **Animate.** `imagine_image_to_video` from the base: one clear motion, in place,
static camera ("the knight walks in place, side view, camera locked",
6s). Keep the shot simple — one subject, one motion. Prefer running this
through **`video2dsprite`** so harvest + chroma are consistent.
3. **Harvest + clean.** Use **`video2dsprite`** scripts (not ad-hoc
`ffmpeg -i … fps=12` alone). Magenta flood-fill / despill lives there;
do not re-key with a different flat `#hex` unless you leave the magenta
pipeline entirely.
4. **Select.** Pick frames that (a) capture the motion's distinct phases and
(b) LOOP — the sequence's end must flow back into its start. Don't force
a count: if the motion reads best with 8, 10, or 12 frames, deliver that
many (more frames = smoother in-engine). For a cycle, select one full
period using motion landmarks (foot contacts, wing extremes, flame peaks).
5. **Package.** Deliver frames in play order (zero-padded names) and/or a
sheet per game-asset-core rules (uniform cells, no dividers) — or the
transparent strips/grids emitted by **`video2dsprite`** /
**`generate2dsprite`**. State the intended fps.
Fall back to keyframe-by-keyframe `imagine_text_to_image` (still on `#FF00FF` when
postprocessing with the sprite scripts) only when video fails the motion
(rare: very stylized poses, single dramatic keyframes) — and then plan
phases yourself and obey the laws below. Prefer **`generate2dsprite`** for
crisp production multi-frame grids.
## Motion laws (verify against these, whatever the pipeline)
- Cycles loop; alternating gaits spend half the period mirrored.
- Continuity: limbs, props, anatomy, effects move on continuous paths —
nothing teleports, vanishes, or duplicates between adjacent frames.
- Physics reads in stills: airborne shows air, anticipation compresses,
follow-through overshoots; effects stay anchored to their origin unless
the request moves them.
- Energy matches the ask: idle/subtle means barely-different frames.
## Verify — the flip test
View the final frames strictly in order and narrate the motion; check
loop closure explicitly (last→first). A hedge in your narration is a
failed frame. The video pipeline usually passes this on the first try —
that's why it's the default.
+69
View File
@@ -0,0 +1,69 @@
---
name: game-asset-core
description: >
Core discipline for ANY game-asset generation with Imagine tools: the
engine-ready defaults users don't state, spec checklists, style anchoring,
read-back verification, honest defect flagging. Use whenever generating
any game art (sprites, sheets, animations, tiles, UI, FX) — then ALSO load
the matching specialist skill: game-animation-frames for anything that moves,
game-tilesets for tiles/terrain, game-character-consistency for recurring characters,
game-ui-icons for UI and icons.
metadata:
short-description: "Core rules + engine-ready defaults for game assets"
user-invocable: false
---
# Asset Core
Game developers ask for WHAT they need, not HOW to make it engine-ready.
The how is your job. Apply these defaults whenever the request doesn't say
otherwise — an asset that needs manual cleanup is a miss even if the user
never mentioned the requirement.
## App-builder note
This skill is **doctrine / QC**. For 2D sprites and motion in this sandbox, also
run the pipeline skills:
- **`generate2dsprite`** / **`video2dsprite`** for sheets and video harvest
- Keyable background on those paths is solid **`#FF00FF`** (required for chroma)
— not an arbitrary flat hex
- Motion laws and loop QC live in **`game-animation-frames`** (which defers
execution to those pipelines)
## Unprompted engine-ready defaults
| When asked for... | Deliver, without being told... |
|---|---|
| a character/creature/prop sprite | isolated subject, flat single-color keyable background (**`#FF00FF`** when using generate2dsprite/video2dsprite), clean silhouette, no baked ground scene or cast shadow |
| anything that moves/animates | a frame SEQUENCE that loops cleanly (see game-animation-frames; execute via video2dsprite/generate2dsprite) |
| a sprite sheet | uniform implicit cells, NO divider lines, subject at the identical position per cell so frames crop at width/cols × height/rows — or build it yourself: frames + PIL composite |
| ground/terrain/water/walls | seamlessly tileable (verify with a real 2×2 composite), no landmark motifs, non-directional lighting where rotation might be used |
| UI panels/frames/buttons | scale-survivable (9-slice: corner ornament, uniform edges), no text ever (games localize), state variants geometry-identical |
| the same character/object again | edit-chained from your existing base image, never regenerated fresh |
| icons | one style contract across the set, uniform padding, legible at 32px |
Deliver organized, exactly-named files; if the request leaves counts or
naming to you, choose sensible names and document them in a manifest/record.
## Working discipline
1. **Spec checklist (private).** List every stated property PLUS the
applicable defaults above. Verify against it; never paste it into
prompts.
2. **Prompt in the generator's language.** 25 vivid sentences, always with
style/medium words. Express geometry/quantity as nameable visual
configurations (clock positions, pie wedges, colored markers), never as
numbers or abstractions.
3. **Verify by describing blind, then diffing.** Write what the image shows
before re-reading the spec. Every stated property AND every applicable
default is pass/fail — no "good enough", no self-negotiated waivers. A
hedge in your own description = failed check.
4. **Escalate representation, then strategy.** Retry once with a more
concrete visual re-expression. If the generator repeats the same failure,
it's a prior: build compositionally (parts + PIL rotate/mirror/assemble —
mind mirrored asymmetries) or keep the best and FLAG it. ~2 discards max
per point being proven.
5. **Deliver and report.** Final pass across all files for cohesion and the
checklist; state every unfixed defect and every default you consciously
deviated from.
@@ -0,0 +1,69 @@
---
name: game-character-consistency
description: >
Deep guide for CHARACTER IDENTITY across images: turnarounds (front/side/
back), state and damage variants, palette swaps, equipment changes, and
same-character-in-context sets. Use whenever generating character
turnarounds, character sheets, variants of an existing sprite, or any
same-subject multi-image set. Complements game-asset-core.
metadata:
short-description: "Same character, every image"
user-invocable: false
---
# Character Consistency
The product is the IDENTITY, not any single image.
Users state WHAT they need, not how — apply everything here even when
the request never mentions it.
## 1. Asymmetry bookkeeping (turnarounds)
Before prompting, write the side-map table for every view. Example: "her
left arm sleeved" →
| view | sleeved arm appears on | staff hand appears on |
|-------|------------------------|----------------------|
| front | viewer's RIGHT | (as designed) |
| right profile | near side = her right = BARE | ... |
| back | viewer's LEFT | mirrored from front |
Prompt each view with VIEWER-relative words from this table, never
body-relative words. Verify each output against the table, not the original
sentence.
## 2. Hands and props
- A held item must be GRIPPED: check the hand-object contact point in every
image. A staff floating beside an open hand = fail.
- The item stays in the SAME hand across all views/frames (mirror it
correctly in back views).
## 3. Edit-chain protocol
- One base image; every view/variant/state via `imagine_image_to_image` with the base
`file_path` (or the nearest neighbor view): "Keep this exact character — same
face, colors, proportions, outfit, scale, background — change only <X>."
- Views must be genuinely rotated (a side view is a strict profile: nose,
chest, toes all pointing at the frame edge), not three slightly-turned
fronts.
- KEEP THE STYLE WORDS in every edit prompt ("stylized 2D game art, cel
shading" or whatever the set uses). Edits without style words drift
toward photorealism.
## 4. Variants (damage / palette / equipment)
- State the freeze-list first in the prompt (pose, framing, background,
everything not being changed), then the single change.
- Damage states are STATES, not action frames: worn, cracked, dented — no
debris flying mid-air.
- Verify by viewing base and variant together: background hue, framing,
proportions, and all unrequested details must match. Escalating states
(hurt → critical) must be strictly ordered when viewed as a set.
## 5. Verify
For every image in the set, describe blind: which side has the marker
detail, what's in each hand, face/proportion match to base. One mismatch =
targeted retry of that image only.
+65
View File
@@ -0,0 +1,65 @@
---
name: game-tilesets
description: >
Deep guide for game TILE assets: seamless tileable textures, terrain
transition tilesets, autotiles, and ground/platform tiles. Use whenever
generating tileable textures, tilesets, terrain transitions, or seamless
patterns. Complements game-asset-core.
metadata:
short-description: "Seamless tiles and transition sets that actually tile"
user-invocable: false
---
# Tilesets
A tile's job is invisibility in repetition.
Users state WHAT they need, not how — apply everything here even when
the request never mentions it.
Judge everything by "will the
player notice the grid?"
## 1. Seamless single tiles
- Prompt for UNIFORM stochastic texture: even density, even lighting, no
shadows with direction, "the pattern continues off every edge".
- The repetition killer is any distinctive motif — one recognizable clump,
flower, or rock repeats forever. Prompt for anonymous texture; verify by
hunting for anything you could point at twice.
- MANDATORY check: composite a real 2×2 repeat with PIL to a throwaway file
and view it. Look for (a) seam lines at the joins, (b) any motif you can
spot in all four quadrants, (c) large-scale tone gradients that create
checkerboarding. Any of the three = retry.
## 2. Transition tilesets (grass→dirt etc.)
- Prompt it as ONE continuous painted image that happens to be sliceable —
never as "tiles", "cells with borders", or anything inviting separated
sticker-tiles with gaps. Cells must be filled edge-to-edge, painted
content flowing across cell boundaries so adjacent tiles genuinely match.
- Layout for a 3×3: center = pure inner material; edge cells = straight
transitions facing outward; corner cells = outer corners. Verify
DIRECTIONALITY per cell (top-center's grass is along its top edge, etc.).
## 3. Rotation economy — make fewer, better tiles
If lighting is neutral (pure top-down, no directional shading), one straight
edge and one outer corner can be ROTATED in-engine to produce all four of
each. So when the tile count is yours to choose:
- Produce: 1 center fill, 1 straight edge, 1 outer corner, 1 inner corner —
then spend the remaining budget on VARIATIONS of the center fill (23
anonymous variants breaks up repetition far better than 4 identical
rotated edges).
- CAVEAT — rotation only works when nothing in the art encodes direction:
no directional light, no gravity cues (hanging grass blades, drips), no
text/emblems. Side-view (platformer) tiles almost always encode gravity
and light, so they need all orientations painted individually. State in
your delivery notes which tiles are rotation-safe.
- If the request explicitly fixes the grid (e.g. "3×3 with all 8
transitions"), deliver exactly that — mention rotation economy in notes,
don't unilaterally change the deliverable.
## 4. Platforms and props
Isolated on a keyable background, consistent lighting with their tileset,
no baked ground shadow (engines composite shadows separately).
+56
View File
@@ -0,0 +1,56 @@
---
name: game-ui-icons
description: >
Deep guide for game UI assets: buttons with interaction states, panels,
bars, wordmark logos, and icon sets. Use whenever generating game UI
elements, HUD assets, inventory icons, icon sets, buttons, or title
logos. Complements game-asset-core.
metadata:
short-description: "Game UI kits and icon sets"
user-invocable: false
---
# Game UI & Icons
UI is a SYSTEM: the set matters more than any piece.
Users state WHAT they need, not how — apply everything here even when
the request never mentions it.
## 1. Interaction states (normal/hover/pressed)
- Generate NORMAL first; hover and pressed are `imagine_image_to_image` edits of it with an
explicit freeze-list: "same shape, same size, same ornament, same frame
thickness, same background — change ONLY <state treatment>".
- Standard treatments: hover = subtle outer glow / slight brighten;
pressed = darker + inset/inner shadow. States must be distinguishable at
a glance AND identical in geometry — overlay-compare: outlines should
coincide, frame thickness included.
## 2. Icon sets
- One style contract for the whole set, decided before generating: same
stroke weight, same fill treatment (all outlined OR all solid — never
mixed), same palette family, same padding, same background, same visual
weight. Verify the set side by side; one icon with a different treatment
(e.g. sitting in a filled tile while others float) fails the SET even if
it's individually fine.
- Generate icon 1, then edit-chain the rest from it to inherit the
contract.
- Each icon must read at 32px: squint-test the thumbnail.
## 3. Panels, bars, wordmarks
- Panels/dialogs: blank, text-ready, borders that survive 9-slicing
(uniform edges, ornament concentrated in corners).
- Bars: clear frame vs fill separation; fill design must work at any
percentage.
- Wordmark logos: image models garble text — generate, then READ THE TEXT
BACK letter by letter; any wrong/merged/extra letter = retry. Deliver as
an isolated asset on flat/keyable background, not a full scene, unless a
title SCREEN is requested.
## 4. No text anywhere else
Buttons, panels, icons: no lettering unless explicitly requested — models
garble it and engines localize it.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 0x0funky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+120
View File
@@ -0,0 +1,120 @@
---
name: generate2dmap
description: >
Generate production-oriented 2D game maps with `imagine_text_to_image`: RPG/top-down maps,
side-scroller parallax stages, tilemaps, layered raster maps, prop packs,
collision zones, and walkable areas. Use when building browser games that
need real map art (not pure code-drawn tiles), layered props, or map
collision metadata. Triggers on "map", "level", "stage", "tilemap",
"overworld", "dungeon", "side scroller background", "prop pack", "2D map".
metadata:
short-description: "2D game maps: layered art, props, collision metadata"
user-invocable: false
---
# Generate2dmap
## App-builder / Grok environment
| Item | Value |
| --- | --- |
| Skill dir / scripts | `.grok/skills/generate2dmap/`, run as `python3 .grok/skills/generate2dmap/scripts/<script>.py …` |
| Image tools | `imagine_text_to_image` / `imagine_image_to_image` (path-based; see **`imagine`**); inspect output with `read_file` on the PNG path (not Codex view_image) |
| Generated image path | `imagine_text_to_image` → sandbox `file_path` → copy into `assets/map/`; Pillow is preinstalled |
| Default `engine_target` | `raw_canvas` or `Phaser` for this TanStack browser sandbox — only use Godot/Unity/Tiled when the user explicitly wants those exports |
| Related skills | **`generate2dsprite`** (character/FX sprites; prop packs still use this skill's extract script), **`building-games`**, **`imagine`** |
## Decide the pipeline first
Build the smallest playable map bundle that satisfies the game: choose a
product-level `map_mode`, then the lower-level axes (`visual_model`,
`runtime_object_model`, `collision_model`, `engine_target`).
- `tile_mode` — editable tile/grid maps: Pokemon-like routes, top-down RPG towns, platformer tilemaps, or any project already on Tiled/LDtk/Godot/Unity/Phaser tilemaps.
- `scene_mode` — foundation base plus separate props: tower defense, survivors-like arenas, cozy top-down showcase maps.
- `side_scroll_mode` — parallax side-scroller stages: Mega Man-like, action platformers, Metroidvania rooms, runners, brawlers.
- `grid_mode` — rule-heavy grids: tactical RPGs, factory/automation, board/card battlers, build grids.
- `room_chunk_mode` — modular rooms/chunks: roguelike dungeons, Metroidvania networks, procedural assembly.
- `baked_scene_mode` — explicitly flat, non-playable scenes only: title/menu screens, battle backdrops, visual-novel scenes, concept art.
Use user-specified parameters when present; otherwise infer the lightest playable
pipeline from the existing game, camera, collision needs, map scale, and editing
needs. When mode and axes disagree, the mode's playable/editable contract wins.
Genre routing, per-mode axis defaults, presets, and the escalation heuristic are in
`references/map-strategies.md` — read it whenever the choice is not obvious.
**A playable map is never one baked image.** For any request implying a playable
map, level, stage, room, prototype, or engine scene, the deliverable must expose
gameplay geometry and objects as separate layers, props, tile/object data,
collision, zones, or engine-native scene nodes. A baked image may be a background,
reference, or preview artifact — never the runtime map — unless the user
explicitly asked for a flat background only.
**Scenes and maps only.** Do not generate character, enemy, boss, NPC, player,
projectile, or animation sprites here; those belong to `$generate2dsprite`. Maps
carry scene hooks (spawn markers, patrol/encounter zones, arena entrances, gates,
exits, camera triggers) as **metadata**, not as drawn art.
## Art comes from image generation, and you write the prompts
- `imagine_text_to_image` is the default art source for base maps, parallax plates,
references, prop sheets, and tileset art. Default `art_style` is `clean_hd`
(hand-painted HD, sharp readable shapes, low texture noise, no chunky pixels);
use `pixel_inspired` or `retro_pixel` only when asked.
- **Write every creative prompt yourself.** Scripts may assemble, slice,
chroma-key, crop, validate, compose previews, and emit JSON/engine files — never
write creative prompts or draw final art. Procedural/placeholder art only when
the user explicitly asks for placeholders, fixtures, debug maps, or scaffolding.
With a tile engine target, generate the tileset art first, then script only the
layers, collision, zones, and scene wiring.
- Save each prompt beside its asset as `<asset>.prompt.txt` (or an explicit
manifest field) whenever the run creates new visual assets.
- **A reference handoff is a file path, not a sentence.** To build on an earlier
image, pass its sandbox `file_path` to `imagine_image_to_image` (and `read_file`
it so you can see it), then name the concrete features to preserve: camera
framing, horizon, road/water shapes, terrain boundaries, entrances/exits,
landmarks. A filename, "based on the map", or the image merely being visible in
conversation is **not** a handoff — stop and pass the path.
## Keep runtime objects out of the base layer
The first generated base/background/foundation image may hold only stable, non-interactive
foundation art — ground material, paths, roads, water, cliffs, floor patterns, lane
markings and build pads; for side views sky, far/mid scenery, silhouettes, atmosphere; for
tilemaps tileset art as editable layers. It must **not** contain tall props, buildings,
trees, rocks, crates, signs, doors, gates, pickups, chests, checkpoints, hazards, traps,
turrets, ladders, foreground occluders, destructibles, actors, enemies, NPCs, UI, labels,
or anything needing collision, interaction, reuse, y-sorting, animation, or its own render
order — regenerate a foundation-only base, or demote such an image to a reference
artifact.
## Reference mockups are checkpoints, not deliverables
Dressed references (top-down) and stage references (side-view) plan placement in-world:
natural game-world objects or subtle blockout geometry, at most **9 distinct visible
object candidates** (repeats count once, then recur in placement metadata), **no
annotation graphics** (circles, arrows, outlines, labels, text, callouts, legends,
measurement lines), and no non-visual metadata — spawns, triggers, patrol hints, camera
bounds are written later as scene hooks.
**Having generated one, do not stop there.** Continue through
`references/object-production-gate.md`: re-`read_file` both images, build the object
list, generate the final separate objects, write placement / collision / scene-hook
metadata, compose the QA preview. Reference-only output is an incomplete run unless
the user explicitly asked for a concept image.
## Depth for the pipeline you picked — open these before producing assets
- Layered raster maps → `references/layered-map-contract.md` (layer types, base and
prop prompt patterns, prop metadata, render order, collision, QA checklist).
- `side_scroll_mode``references/side-scroll-stages.md`: the `stage_canvas`
decision, the named scenery-only parallax layers, and the mandatory in-world
stage reference before any platform/object work.
- Any prop or scene-object generation → classify each object first, then follow
`references/prop-pack-contract.md`: only compact props may share a square prop
pack; platforms, floors, bridges, gates, buildings and anything collision-aligned
go one-by-one, as a platform strip, a custom wide pack, or tile/object layers.
- Parameters, the step-by-step workflow, and the `extract_prop_pack.py` /
`compose_layered_preview.py` commands → `references/pipeline.md`.
- Deliverable lists per pipeline and the validation checklist →
`references/deliverables.md`; run both before calling a map done.
+6
View File
@@ -0,0 +1,6 @@
# Source
Vendored from [agent-sprite-forge](https://github.com/0x0funky/agent-sprite-forge) (`53dce6055984c610d833e77887939cbd0fb1c92b`).
MIT License — see `LICENSE`.
Adapted for Grok Build / app-builder sandbox (image tool paths, `read_file` for image inspection, workspace-relative script paths).
@@ -0,0 +1,101 @@
# Expected deliverables and validation
Check the deliverable list for the pipeline you chose before calling a map
done, then run the validation list against what you actually wrote.
For a baked raster map:
- `assets/map/<name>.png`
- optional `<name>.prompt.txt`
- optional `data/<name>-collision.json` or `data/<name>-zones.json`
- code changes that load/use the image
Use this deliverable only for non-playable backgrounds or explicitly requested flat images. If actors must move through the scene, collide with level geometry, jump on platforms, collect items, trigger doors, or edit the level later, upgrade to a layered, parallax-stage, tilemap, or engine-native deliverable.
For a layered raster map:
- `assets/map/<name>-base.png`
- `assets/map/<name>-base.prompt.txt`
- optional `assets/map/<name>-dressed-reference.png` for prop planning
- `assets/props/<prop>/prop.png` folders, from one-by-one props or extracted prop packs
- `data/<name>-props.json` placement metadata
- `data/<name>-collision.json` and/or `data/<name>-zones.json` when gameplay needs them
- `assets/map/<name>-layered-preview.png`
- code changes that load the base, props, y-sorted renderables, collision, and zones
For a tilemap or layered tilemap:
- image-generated or user-supplied `assets/tilesets/<name>.png`
- optional tile slicing/atlas metadata
- engine-native tile layer data such as Tiled JSON, LDtk data, Godot TileMap scene data, Unity tile placement data, or project-native JSON
- object layers for spawns, exits, interactables, blockers, and zones
- a flattened preview assembled from the visual tileset and layer data
- no script-drawn final tileset art unless the user explicitly asked for procedural placeholders
For a playable side-view scrolling/action stage:
- image-generated parallax scenery layers such as `assets/map/<name>-sky.png`, `assets/map/<name>-far-bg.png`, `assets/map/<name>-mid-bg.png`, `assets/map/<name>-near-bg.png`, and optional `assets/map/<name>-foreground-overlay.png`
- one recorded `stage_canvas` shared by the primary parallax layers, `stage-reference`, and `stage-preview`
- `assets/map/<name>-background.prompt.txt` and prompt files/manifests for other generated visual assets
- `assets/map/<name>-stage-reference.png` as an in-world reference mockup for platform/object placement
- separate image-generated platform, terrain-chunk, foreground-occluder, hazard, door, pickup, checkpoint, gate, and exit sprites when these are visible scene objects
- `data/<name>-objects.json` or engine-native object layers for platforms, terrain chunks, hazards, pickups, doors, checkpoints, gates, exits, and foreground occluders
- `data/<name>-scene-hooks.json` or engine-native metadata for player spawns, actor spawn marker metadata, encounter/arena triggers, camera bounds, and exit links
- `data/<name>-collision.json` with explicit platform/solid geometry independent from the background pixels
- `assets/map/<name>-stage-preview.png` composed from the background plus objects for QA only
- code or scene changes that load the background, render object layers, and use the collision/object data as runtime gameplay data
Do not accept a single generated side-view action/platformer stage image plus collision rectangles as the final playable map. The stage must expose platforms or walkable lanes, hazards, doors, pickups, checkpoints, gates, exits, scene hooks, and camera bounds as separate runtime objects, tile/object layers, or metadata. Runtime `background` fields must point to the scenery-only background or parallax layer, never to `stage-reference` or `stage-preview`; previews are QA artifacts only.
For `grid_mode`:
- image-generated or user-supplied tileset/grid art
- grid dimensions, cell size, and map data in project-native JSON, Tiled JSON, LDtk, Godot TileMap, Unity Tilemap, or equivalent
- cell metadata for walkable/buildable, movement cost, terrain effects, resources, collision, and placement rules
- object layers for units, buildings, machines, cards/board slots, exits, spawns, and triggers
- a QA preview that can show optional debug grid/collision overlays
For `room_chunk_mode`:
- reusable chunk art or tile/object layers
- chunk metadata with `chunk_id`, size, entrances/exits, connection sockets, spawn markers, blockers, hazards, and camera bounds
- collision and seam validation metadata
- a chunk preview and, when multiple chunks exist, an assembled layout preview
For `scene_mode`:
- foundation-only `assets/map/<name>-base.png`
- in-world `assets/map/<name>-dressed-reference.png`
- separate props/interactables/blockers from one-by-one assets or compact prop packs
- placement, collision, zones, exits, camera bounds, and scene-hook metadata
- a QA preview composed from the base plus final runtime objects
For a prop pack:
- raw generated sheet with solid `#FF00FF` background
- extracted `assets/props/<prop>/prop.png` files
- `prop-pack.json` extraction manifest
- no `edge_touch` entries for accepted props
## Validation
Always validate what the chosen pipeline requires:
- map files exist and have expected dimensions
- prompt files or prompt manifest fields exist for generated visible assets
- transparent props contain alpha
- prop pack manifests parse and accepted props do not touch cell edges
- placement JSON parses and referenced prop files exist
- collision/zones JSON parses when present
- critical spawn, path, entrance, blocker, and zone points behave as expected
- playable/editable layered maps use a foundation-only base/background and do not bake runtime-controlled props, interactables, hazards, doors, gates, pickups, actors, foreground occluders, or reusable scene objects into the base
- playable stages have explicit runtime objects or metadata for every gameplay-relevant platform or walkable lane, blocker, hazard, door, pickup, checkpoint, gate, exit, player spawn, actor spawn marker, encounter/arena trigger, and camera bound
- playable side-view backgrounds are scenery-only and do not contain baked-in foreground gameplay platforms, hazards, pickups, doors, gates, checkpoints, or other reusable runtime objects
- `side_scroll_mode` primary parallax layers, stage references, and stage previews match the recorded `stage_canvas`; any repeatable strips or differently sized foreground sprites declare display size, anchor, scale, and repeat policy
- `side_scroll_mode` parallax layers have explicit render order, scroll factors, dimensions, loop/repeat policy, and are not used as collision sources
- `grid_mode` outputs include grid dimensions, cell size, cell metadata, object layers, and validation of critical walkable/buildable cells
- `room_chunk_mode` outputs include chunk dimensions, exits/connection sockets, seam validation, collision, and at least one assembled or per-chunk preview
- stage-reference maps preserve the background dimensions and their object plan matches the final object/collision metadata
- stage-reference and dressed-reference mockups contain no more than 9 distinct visible runtime prop/object candidates unless the user explicitly requested a larger pass
- reference mockups are followed by final props/objects, placement metadata, collision/scene-hook metadata, and a QA preview unless the user explicitly requested reference-only output
- flattened preview looks coherent at the game's camera size
@@ -0,0 +1,209 @@
# Layered Raster Map Contract
Use this contract for hand-painted or generated 2D RPG scenes, monster-taming exploration maps, shrine/town/dungeon maps, and any top-down scene where actors must interact with props.
## Layer Types
1. `base`: one raster image containing only terrain and ground-level details.
2. `props`: transparent sprites anchored in map coordinates.
3. `actors`: player, NPCs, monsters, pickups, and moving objects.
4. `foreground`: optional transparent sprites that must cover actors.
5. `collision`: structured metadata, not pixels.
6. `zones`: structured metadata for encounters, rest, triggers, exits, and dialogue.
7. `preview`: flattened QA artifact only.
## Base Map Prompt Pattern
Default to clean HD maps for gameplay readability unless the user explicitly asks for pixel art:
```text
Create a clean hand-painted top-down 2D RPG game map.
This is a BASE GROUND MAP ONLY for a layered raster exploration scene.
Style: clean HD game asset style, sharp readable terrain shapes, crisp silhouettes, smooth painted surfaces, low texture noise, controlled accent lighting.
Do not make pixel art. Avoid chunky pixels, retro dithering, noisy microtexture, tiny debris, clutter, blurry painterly mush, and over-detailed grime.
Include terrain, paths, grass/water/floor materials, ground markings, floor patterns, and flat anchor pads.
Do not include tall collidable objects: no buildings, gates, fences, lanterns, trees, signs, barrels, NPCs, monsters, UI, or text.
Leave clear empty spaces where props will be placed later.
Make walkable paths and zone boundaries easy to trace.
```
If the user wants a pixel-adjacent look, use `clean modern pixel-art-inspired` and still forbid heavy dithering and noisy microtexture. Use `16-bit pixel art`, `retro JRPG pixel art`, or similar terms only when the user explicitly asks for a retro pixel look.
## Prop Generation
Use `$generate2dsprite` when the map needs reusable transparent props. Choose one of two approaches:
- One-by-one props: safest for large, important, irregular, animated, or identity-critical props.
- Prop packs: faster for sets of small/medium static environmental props.
Read [prop-pack-contract.md](prop-pack-contract.md) before batching props.
## Dressed Reference Pass
For generated layered raster maps, use a dressed reference pass before final prop extraction:
1. Generate the base as ground-only terrain.
2. Hand the base to built-in `imagine_image_to_image` as a real reference: pass its sandbox `file_path` to `imagine_image_to_image`. Also call `read_file` so you can see it. Do not expect a filesystem path in the prompt, or the image merely being visible in conversation, to work as the visual reference.
3. Ask for a dressed-reference version of the same map by adding props only.
4. Preserve exact camera, framing, dimensions, terrain, paths, water, anchor pads, collision-relevant boundaries, and map edges.
5. Use the dressed reference to choose prop identities and placement coordinates, but compose the final runtime preview from the original base plus extracted transparent props.
The dressed reference is a planning artifact. Do not ship it as the only runtime map when props need collision, y-sort, occlusion, or reuse.
Prompt shape:
```text
Use the provided reference image as the exact base map reference.
Create a dressed-reference version of the same map by adding props only.
Preserve exactly: camera, framing, image size, terrain, paths, water, anchor pads, rocks, map boundaries, and all walkable routes.
Do not crop, zoom, rotate, repaint, or redesign the terrain.
Add these props naturally on top of the existing map: <list>.
Props should feel intentionally placed along paths, landmarks, encounter-zone edges, rest points, and entrances.
No UI, no text, no labels, no watermark.
```
## One-By-One Prop Prompt Pattern
```text
Create a single <prop> prop for a top-down 2D RPG map.
Use the same selected map art style: clean HD hand-painted by default, pixel-inspired only when requested, retro pixel only when explicitly requested.
Mostly front-facing top-down RPG object view: upright objects are vertical and centered, with only a small visible top face. Avoid strong isometric diagonal rotation.
Full object visible, centered, crisp but not chunky outlines.
Background must be 100% solid flat #FF00FF magenta, no gradients, no texture, no shadows, no floor plane.
No text, labels, UI, or watermark.
Entire prop must fit fully inside the image with generous magenta margin on all sides; no part may touch or cross the image edge.
```
Recommended processing:
```bash
python /path/to/generate2dsprite.py process \
--input <raw.png> \
--target asset \
--mode single \
--rows 1 \
--cols 1 \
--cell-size 256 \
--output-dir assets/props/<prop> \
--fit-scale 0.9 \
--align feet \
--component-mode largest \
--component-padding 8 \
--min-component-area 200 \
--threshold 100 \
--edge-threshold 150 \
--edge-clean-depth 2
```
Use a larger `--cell-size` for buildings, trees, gates, statues, or large signs.
## Prop Metadata
Use explicit map-space dimensions:
```json
{
"props": [
{
"id": "torii",
"image": "assets/props/torii/prop.png",
"x": 836,
"y": 850,
"w": 380,
"h": 306,
"sortY": 850,
"layer": "props"
}
]
}
```
Anchor conventions:
- `x`: center of the prop's base/feet.
- `y`: bottom of the prop in map coordinates.
- `w`, `h`: rendered size in map units.
- `sortY`: y-depth used for render ordering. Use base `y` for normal props.
- `layer`: `props` for y-sorted objects, `foreground` for always-over actors overlays.
## Render Order
Recommended order:
```text
base map
ground effects / zone glimmers
renderables sorted by sortY:
props
actors
foreground overlays
debug collision
HUD/UI
```
If an NPC must always appear above the player, draw that NPC after the y-sorted pass or set a high `sortY`.
## Collision Metadata
Keep collision readable and hand-editable:
```json
{
"mapSize": { "width": 1672, "height": 941 },
"spawn": { "x": 836, "y": 782 },
"walkBounds": [
{ "id": "main-courtyard", "type": "ellipse", "x": 838, "y": 548, "rx": 604, "ry": 304 }
],
"blockers": [
{ "id": "torii-left-pillar", "type": "rect", "x": 704, "y": 668, "w": 52, "h": 176 }
],
"zones": {
"grass": { "type": "rect", "x": 180, "y": 306, "w": 382, "h": 302 },
"rest": { "type": "circle", "x": 760, "y": 548, "radius": 122 }
}
}
```
Guidelines:
- Use blockers for prop bases, not full sprite silhouettes.
- Keep entrances open by testing path centers.
- Use ellipses for lanterns, rocks, trees, and basins.
- Use rectangles for fences, walls, buildings, gates, bridges, and posts.
- Use polygons only when rects/ellipses produce poor walkability.
## Preview Composition
Use `scripts/compose_layered_preview.py` to flatten a base map and placement JSON:
```bash
python3 .grok/skills/generate2dmap/scripts/compose_layered_preview.py \
--base assets/map/shrine-base.png \
--placements data/shrine-props.json \
--output assets/map/shrine-layered-preview.png
```
The script assumes prop placement uses center-bottom anchoring unless a prop explicitly sets another anchor.
## QA Checklist
- Spawn point is walkable.
- Main path centers are walkable.
- Gate centers are walkable if the player should pass through.
- Gate pillars block.
- Fences block but entrances remain open.
- Interactables block at their base but can be approached.
- Encounter/rest zones are reachable.
- Actors sort correctly when walking in front of and behind tall props.
- The flattened preview matches the in-game layered render closely enough for visual review.
## Anti-Patterns
Avoid:
- Cutting props out of a fully baked generated map.
- Using a complete flattened map as the only source when collision/occlusion matters.
- Baking text, signs, UI, NPCs, or monsters into the base.
- Letting prop sprites touch image edges.
- Treating transparent PNG bounding boxes as collision automatically.
- Updating art without updating collision and critical point tests.
@@ -0,0 +1,341 @@
# Map Pipeline Selection
Choose maps by first selecting a product-level `map_mode`, then mapping that mode to pipeline axes. Avoid treating `hybrid` as a top-level strategy; most real 2D maps are hybrid combinations of visual art, objects, and collision metadata.
## Core Map Modes
Use these modes as the first decision layer:
- `tile_mode`: editable tile/grid maps. Use for Pokemon-like routes, top-down RPG towns, monster-taming exploration, platformer tilemaps, tactical maps, factory maps, and projects that already use Tiled, LDtk, Godot TileMap, Unity Tilemap, or Phaser tilemaps.
- `scene_mode`: foundation/base map plus separate props. Use for tower defense, survivors-like arenas, cozy/top-down showcase maps, visual adventure scenes, and base-map-plus-props requests.
- `side_scroll_mode`: parallax side-scroller scenes. Use for Mega Man-like, action platformer, Metroidvania side rooms, runners, side-view shooters, and brawlers.
- `grid_mode`: rule-heavy grid scenes. Use for tactical RPGs, factory/automation games, board/card battlers, build grids, terrain-cost maps, and resource maps.
- `room_chunk_mode`: modular room/chunk generation. Use for roguelike rooms, dungeon chunks, procedural level assembly, and Metroidvania room networks.
- `baked_scene_mode`: fixed visual backgrounds. Use only for title screens, visual novel backgrounds, point-and-click scenes, boss arena concept art, non-playable showcase images, or explicit flat-image requests.
Modes are not final file formats. After choosing a mode, still define `visual_model`, `runtime_object_model`, `collision_model`, and `engine_target`.
## Genre Routing Table
| User asks for | Default mode | Notes |
| --- | --- | --- |
| Pokemon-like, monster-taming RPG, top-down RPG route/town | `tile_mode` | Add props, encounter zones, exits, NPC/actor spawn markers, and collision. |
| Tower defense, Kingdom Rush-like | `scene_mode` | Add path metadata, build slots, spawn/exit hooks, blockers, and optional engine scene scaffold. |
| Survivors-like arena | `scene_mode` or `tile_mode` | Use sparse blockers, spawn zones/rings, camera bounds, and props. |
| Mega Man-like, side-view action, platformer, runner | `side_scroll_mode` | Use parallax layers plus platform/object/collision metadata. |
| Metroidvania | `side_scroll_mode` or `room_chunk_mode` | Use room/chunk exits and camera bounds; use tilemap when the engine expects grid collision. |
| Beat-em-up / brawler | `side_scroll_mode` | Use parallax/background depth plus a walkable belt polygon, enemy wave zones, and props. |
| Tactical RPG, grid strategy | `grid_mode` | Store terrain, move cost, defense/effects, unit slots, and collision. |
| Factory / automation | `grid_mode` | Store buildable cells, resource nodes, machine slots, belts/conveyors, and item lanes. |
| Card/board battler or UI-heavy game | `grid_mode` | Store board slots, UI zones, interaction regions, and background art. |
| Roguelike room, procedural dungeon, modular rooms | `room_chunk_mode` | Store chunk sockets, exits, collision, spawn markers, and seam validation. |
| Visual novel, title screen, fixed battle background | `baked_scene_mode` | Use only when no runtime editing/collision is needed. |
## Playable Map Default
When the user asks for a playable game map, level, stage, room, prototype, or engine scene, the runtime map must not be only a flattened generated image. A single baked image can be used as a background, in-world reference mockup, or QA preview, but playable output needs explicit runtime structure:
- top-down maps: ground/base layer plus separate props, object placement, collision, zones, exits, and spawn data
- side-view scrolling/action stages: background/parallax layers plus an in-world stage reference mockup, platform objects or walkable lanes, terrain chunks, foreground occluders, hazards, doors, pickups, checkpoints, scene hooks, camera bounds, and collision
- tile/editor workflows: generated or supplied tileset art plus tile layers, object layers, collision, zones, and engine-native scene/map data
If the request mentions "game", "playable", "prototype", "level", "stage", "side-view action", "side-scroller", "platformer", "Megaman-like", "RPG exploration", "tower defense", or engine integration, start from the nearest playable preset below instead of `baked_raster`.
## Mode Deliverable Contracts
### `tile_mode`
Deliver tileset art, map data, tile layers, object layers, collision, exits, and a preview. Good output formats include Tiled JSON, LDtk, Godot TileMap, Unity Tilemap, Phaser tilemaps, or project-native JSON.
Use `generate2dsprite` only when reusable transparent props, NPCs, animated objects, or non-tile scene objects are actually needed. A pure terrain map can stay tileset + map data + collision metadata.
### `scene_mode`
Deliver a foundation-only base map, an in-world dressed reference, final separate props/interactables/blockers, placement metadata, collision/zones/exits/camera bounds, and a composed QA preview.
This is the default for beautiful top-down demos, tower defense scenes, survivors-like arenas, and base-map-plus-props workflows.
### `side_scroll_mode`
Deliver scenery-only parallax layers plus separate playable foreground objects. Typical visual layers:
- `sky`
- `far_bg`
- `mid_bg`
- `near_bg`
- optional `foreground_overlay`
Then deliver platform tiles/objects, terrain chunks, hazards, doors, checkpoints, pickups, exits, camera bounds, scroll factors, collision, scene hooks, and a QA preview. Parallax layers create depth; they are not collision sources.
Choose one `stage_canvas` before generation. The primary parallax plates, stage reference, and QA preview must share the same pixel dimensions, aspect ratio, camera framing, horizon, and top-left anchor. Default to the project camera aspect ratio; when unknown, use a 16:9 side-scroller canvas such as `1536x864`.
For brawlers, use the same mode but replace jump-platform geometry with a walkable belt polygon, foreground/background props, enemy wave zones, and camera locks.
### `grid_mode`
Deliver grid dimensions, cell size, tiles/cells, terrain metadata, walkable/buildable flags, movement cost, resource or terrain effects, collision, object layers, and a preview with optional debug overlay.
Prioritize validation over beauty. The map must be readable by game logic.
### `room_chunk_mode`
Deliver reusable room/chunk art or tile/object layers, chunk dimensions, exits, connection sockets, collision, spawn markers, camera bounds, and seam validation. If multiple chunks exist, also deliver an assembled layout preview.
### `baked_scene_mode`
Deliver a fixed image plus optional coarse collision/zones only. Do not use this mode for editable or playable maps unless the user explicitly requests a flat background.
## Visual Asset Source
Default to built-in image generation for visual assets. Base maps, in-world reference mockups, dressed references, stage references, prop sheets, prop sprites, tileset art, parallax layers, and battle backgrounds should come from `imagine_text_to_image` unless the user supplies existing art or explicitly asks for procedural placeholders.
Scripts may slice, assemble, chroma-key, validate, compose previews, create metadata, and emit engine files. They must not replace image generation as the creative art source for final map visuals. Engine outputs such as Godot `.tscn`, Tiled JSON, LDtk data, or Unity placement data should wire up image-generated or user-supplied assets.
## In-World Reference Mockups
Use an in-world reference mockup whenever object placement must be visually coherent but the final runtime needs separate objects.
- Top-down layered maps use `assets/map/<name>-dressed-reference.png`: base map plus proposed props rendered as natural game-world objects.
- Side-view scrolling/action stages use `assets/map/<name>-stage-reference.png`: background/parallax base plus proposed platforms or walkable lanes, hazards, pickups, doors, checkpoints, gates, and exits rendered as natural game-world objects or subtle in-world blockout geometry.
- Reference mockups must preserve exact camera, framing, dimensions, terrain/background, entrances, exits, and collision-relevant boundaries from the base image.
- Reference mockups should include at most 9 distinct visible runtime prop/object candidates unless the user explicitly asks for a larger pass. Repeated placements of the same object count as one candidate and should be repeated later in placement metadata.
- Reference mockups are planning artifacts only. Do not ship them as runtime maps, infer collision from their pixels, or cut final platform/prop assets out of them.
- Final output must still use separate props/platform objects, scene-object metadata, collision, zones, scene hooks, tile/object layers, or engine-native nodes.
- Character, enemy, boss, projectile, player, NPC, and animation sprites are outside the map deliverable. Store actor spawn markers and encounter/arena hooks as metadata only, then use `$generate2dsprite` if those actor assets are needed.
- Do not create annotated diagrams. Reference mockups must not contain circles, arrows, outlines, labels, numbers, UI callouts, text, captions, legends, highlighted boxes, highlighted zones, measurement lines, or explanatory overlays.
- Do not stop after the reference mockup. Reference-only output is incomplete unless the user explicitly asked for a reference-only concept image.
## Visual Reference Handoff
Reference mockups must be generated from the actual base/background image, passed to the image model as a real reference:
1. Save the base/background image first and keep its sandbox `file_path`.
2. Pass that `file_path` on the reference-mockup `imagine_image_to_image` call. Also call `read_file` on the saved image so you can see it yourself.
3. The image prompt must explicitly say to use the provided reference image as the visual reference.
4. The prompt must name concrete features from the viewed image to preserve: camera framing, dimensions, horizon, terrain boundaries, road/water shapes, entrances, exits, major silhouettes, and landmark positions.
5. The prompt must ask for an in-world reference mockup, not an annotated planning diagram.
6. The prompt should render only visible scene objects: props, platforms, terrain chunks, hazards, gates, pickups, checkpoints, doors, exits, foreground occluders, or subtle blockout geometry.
7. Non-visual data such as player spawns, actor spawn markers, camera bounds, patrol hints, and encounter/arena triggers must be written later as scene-hook metadata, not drawn into the image.
Do not rely on filenames, paths, vague phrasing such as "based on this map", or the image merely being visible in conversation. If the base/background is not wired into the call as a sandbox `file_path`, stop and pass it before generating the dressed reference or stage reference.
## Layer Separation Contract
For any playable or editable layered map, the first generated base/background/foundation image must not bake in objects that the runtime should control separately. This applies across top-down RPG maps, monster-taming maps, tactical arenas, tower-defense lanes, side-view platformers, parallax stages, tile/editor workflows, clean HD, pixel-inspired, and retro pixel art.
Allowed in the base/background/foundation layer:
- top-down or 3/4 maps: ground material, paths, roads, water, cliffs, low terrain markings, floor patterns, and terrain boundaries
- tactical or tower-defense maps: ground, lanes, roads, build pads, lane markings, terrain zones, and non-interactive floor detail
- side-view stages: sky, far/mid scenery, distant buildings, distant terrain silhouettes, atmosphere, and non-colliding depth
- tilemaps: tileset art and editable tile layers, not a flattened full-scene background
Not allowed in the runtime base/background/foundation layer unless the user explicitly asks for a single baked image:
- tall props, buildings, trees, rocks, crates, signs, doors, gates, pickups, chests, checkpoints, hazards, traps, turrets, tower objects, ladders, foreground occluders, destructibles, actors, enemies, NPCs, bosses, player characters, UI, labels, or any object that needs collision, interaction, replacement, reuse, y-sorting, animation, engine editing, or independent render order
If a generated base/background contains runtime-controlled objects, regenerate a cleaner foundation-only base or demote that image to a concept/reference artifact. Proposed objects belong in the in-world reference mockup, then in final separate props, platform objects, object layers, tile layers, collision, zones, and scene-hook metadata.
## Side-Scroll Parallax Contract
`side_scroll_mode` uses parallax background as a core stage-building method. It should produce a layered depth stack, not one crowded full-stage painting.
Typical layer responsibilities:
- `sky`: sky, moon/sun, far atmosphere; scroll factor near `0.0` to `0.1`.
- `far_bg`: mountains, skyline, far castle/factory silhouettes; slow scroll factor.
- `mid_bg`: readable landmarks and large distant structures; medium scroll factor.
- `near_bg`: near non-colliding scenery behind gameplay objects; faster scroll factor but still not collision.
- `foreground_overlay`: optional fog, chains, pipes, silhouettes, smoke, or framing elements that render above actors but do not define gameplay collision.
Generate parallax layers as scenery-only art. Platforms, walkable floors, ladders, hazards, gates, doors, pickups, checkpoints, and collision-critical props belong in platform/object/tile layers, not in parallax backgrounds.
All primary parallax plates must use the same `stage_canvas`. If image generation returns inconsistent dimensions, regenerate or normalize the generated layer before runtime use. Do not rely on the engine to guess scaling between mismatched layer sizes. Repeatable strips may have different source widths only when metadata records display size, anchor, scale, repeat axis, and loop policy.
The final side-scroller should feel deeper than a single flat image: distant layers move slowly, near layers move faster, and gameplay objects stay on their own runtime layer.
## Side-View Background Separation
For playable side-view scrolling/action stages, the general layer separation contract becomes stricter: the background is scenery-only. It should be a far/mid depth plate that separate runtime objects can stack over cleanly.
Allowed in the background:
- sky, clouds, mountains, distant city/castle silhouettes, far walls, smoke, weather, atmospheric depth, and non-colliding distant landmarks
- optional separate parallax midground/foreground layers when they are not gameplay geometry
Not allowed in the runtime background:
- walkable floors, platform tops, terrain chunks, ladders, spike traps, pickups, crates, doors, gates, checkpoints, near fences, near walls, foreground barricades, enemies, player characters, UI, labels, or any object that should be edited, collided with, reused, or rendered independently
If a generated side-view background contains obvious foreground gameplay geometry, reject it as a runtime background and regenerate a cleaner scenery-only background. Do not set flattened `stage-reference` or `stage-preview` images as the runtime background.
## Post-Reference Object Production
After a dressed reference or stage reference exists, continue into final runtime production:
1. Make both the original base/background and the dressed/stage reference mockup visible in conversation context. For local files, call `read_file` on both images immediately before object-list extraction or object/prop generation.
2. Create a concrete object list from the visible reference mockup while cross-checking the original base/background: object id, type, approximate position, approximate size, render layer, collision role, and asset strategy.
3. For each visible runtime object, generate a separate transparent asset, extract it from a generated pack, or represent it as a tile/object layer when the engine/editor pipeline is tile-based.
4. For object/prop generation that must match the map style, pass the base/background and/or reference mockup sandbox paths, and state in the prompt that the provided reference images are the visual context. The generated asset must match the original map style and correspond to an object visible in the reference mockup.
5. Generate or define the final props, platforms, terrain chunks, hazards, pickups, doors, gates, checkpoints, exits, foreground occluders, and other visible scene objects. Do not rely on the reference image as the runtime art for these objects.
6. Write placement metadata, object layers, collision data, scene hooks, camera bounds, exits, and zones.
7. Compose a QA preview from the original base/background plus the final runtime objects.
For playable maps, layered maps with props, side-view stages, engine scenes, and requests for separate/editable props, stopping after the reference mockup is a failed/incomplete run.
For prop packs or object packs generated after a reference mockup, derive the object list and prompt from the visible reference mockup and original base/background. Do not generate generic props from memory or filenames.
## Visual Model
### `baked_raster`
Use when:
- the scene is static, decorative, fixed-screen, or visual-first
- the game needs a battle background, title scene, menu backdrop, cutscene, or quick prototype
- collision is absent or can be represented by a few invisible shapes
- the user explicitly asks for a single flat image or background
Deliver one image generated or edited through image generation, plus optional collision/zones metadata.
Do not use this as the final runtime map for platformers, RPG exploration, tower defense, or any scene where props, platforms, hazards, exits, or interactables must be edited, collided with, reused, or rendered independently.
### `layered_raster`
Use when:
- a hand-painted or generated base map is best, but tall objects need collision, occlusion, interaction, reuse, or later editing
- the scene is an RPG town, shrine, dungeon room, field, interior, or monster-taming exploration map
- y-sorted actors should walk in front of and behind props
Deliver an image-generated ground-only base image, separate image-generated props, placement metadata, collision/zones metadata, and a flattened preview.
The base image must be foundation-only: terrain, roads, water, floor markings, and boundaries are allowed; tall props, buildings, trees, signs, doors, chests, pickups, actors, hazards, and occluders must be separate assets or object/tile layers.
### `tilemap`
Use when:
- the engine/editor already uses Tiled, LDtk, Phaser tilemaps, Godot TileMap, Unity Tilemap, or similar tooling
- the user asks for tiles, tilesets, tile collision, autotiling, or editable grid-perfect maps
- procedural generation, large maps, or editor workflows matter
Deliver image-generated or user-supplied tileset images, engine-native map data, tile layers, object layers, and tile/object collision. Do not script-draw the tileset as final art unless the user explicitly asked for procedural placeholders.
Do not flatten tile layers, object layers, collision-relevant props, pickups, doors, hazards, or interactables into one runtime background image.
### `layered_tilemap`
Use when:
- the game needs multiple tile layers such as ground, decor, walls, overhead, and foreground
- actors need to pass under selected tile layers
- collision and triggers are tile/object-layer driven
Deliver image-generated or user-supplied tileset art, layered tile data, and a render-order contract.
### `parallax_layers`
Use when:
- the map is a side-scroller, platformer, runner, shooter, side-view brawler, scrolling action stage, or scrolling backdrop
- background depth matters more than top-down collision
Deliver image-generated background, midground, foreground, and scroll-speed metadata.
For a playable side-view scrolling/action stage, parallax layers are only the scenery. Generate an in-world stage reference mockup from the visible background using the visual reference handoff, then continue through post-reference object production. The playable stage still needs separate runtime objects for platforms or walkable lanes, terrain chunks, hazards, pickups, doors, checkpoints, gates, exits, scene hooks, camera bounds, and explicit collision.
The runtime background for this preset must be scenery-only. Put collidable foreground geometry and reusable gameplay objects into `platform_objects`, tile/object layers, or engine-native nodes instead of baking them into the background image.
For `side_scroll_mode`, use named parallax layers (`sky`, `far_bg`, `mid_bg`, `near_bg`, optional `foreground_overlay`) plus explicit scroll factors, shared `stage_canvas`, and loop/repeat policy. Do not treat a single scenery background as a complete side-scroller background stack unless the user explicitly asks for a flat/non-parallax background.
## Runtime Object Model
- `none`: the map is just a background or tile layers.
- `separate_props`: props are independent sprites but do not require y-sort.
- `platform_objects`: platforms, walkable lanes, terrain chunks, walls, hazards, foreground blockers, and other collidable stage geometry are independent runtime objects with placement and collision data.
- `y_sorted_props`: props and actors sort by base `y`; use for top-down RPG scenes.
- `interactive_scene_objects`: doors, pickups, switches, checkpoints, gates, destructibles, signs, exits, and other non-character scene objects with interaction or state.
- `foreground_occluders`: selected overlays always draw over actors.
- `scene_hooks`: metadata-only markers such as player spawn, actor spawn markers, encounter zones, patrol hints, arena triggers, camera bounds, exit links, and checkpoint ids. These do not require generated actor art.
Use the simplest model that can express collision and occlusion correctly.
## Collision Model
- `none`: visual-only maps and simple backgrounds.
- `coarse_shapes`: a few rectangles/ellipses for fixed arenas or decorative maps.
- `precise_shapes`: explicit blockers and walk bounds for layered RPG maps.
- `tile_collision`: collision stored per tile or tile layer.
- `polygon_walkmesh`: irregular walkable regions or constrained path maps.
- `trigger_zones`: encounter/rest/exit/dialogue areas; often combined with another collision model.
Do not infer collision from prop PNG bounds automatically. Use explicit blockers for prop bases and explicit walkable zones for navigation.
## Engine Target
- `raw_canvas`: use PNG assets, JSON metadata, and project-specific render code.
- `Phaser`: prefer atlas/tilemap JSON when the project already uses Phaser loaders; visual assets still come from image generation or existing art.
- `Tiled_JSON`: produce Tiled-compatible tilesets, layers, objects, and custom properties around image-generated or existing tileset art.
- `LDtk`: produce or adapt to LDtk entity/layer concepts if the project uses LDtk, while preserving image-generated or existing art as the visual source.
- `Godot_TileMap`: produce tile layers and scene metadata matching Godot's structure after generating or selecting the visual tileset art.
- `Unity_Tilemap`: produce tileset/sprite assets and placement data for Unity workflows after generating or selecting the visual art.
- project-native: preserve existing schema when a game already has one.
## Presets
### Fixed Battle Background
- `visual_model`: `baked_raster`
- `runtime_object_model`: `none`
- `collision_model`: `none` or `coarse_shapes`
- Typical deliverables: one PNG, optional zones.
### RPG Exploration Scene
- `visual_model`: `layered_raster`
- `runtime_object_model`: `y_sorted_props`
- `collision_model`: `precise_shapes + trigger_zones`
- Typical deliverables: base map, prop images, placement JSON, collision JSON, preview.
### Monster Grassland
- `visual_model`: `layered_raster`
- `runtime_object_model`: `y_sorted_props + interactive_scene_objects + scene_hooks`
- `collision_model`: `precise_shapes + trigger_zones`
- Good prop-pack candidates: rocks, shrubs, flowers, signs, small logs.
### Tile-Based Dungeon
- `visual_model`: `layered_tilemap`
- `runtime_object_model`: `interactive_scene_objects + scene_hooks`
- `collision_model`: `tile_collision + trigger_zones`
- Use only when the engine/editor supports tilemaps.
### Side-View Scrolling Stage
- `visual_model`: `parallax_layers`
- `runtime_object_model`: `platform_objects + interactive_scene_objects + scene_hooks + foreground_occluders`
- `collision_model`: `precise_shapes` or engine-native platform/object collision
- Typical deliverables: shared `stage_canvas`, separate parallax layers (`sky`, `far_bg`, `mid_bg`, `near_bg`, optional `foreground_overlay`) matching that canvas, scroll factors, in-world stage reference mockup, separate platform/terrain sprites, foreground pieces, hazards, pickups, doors, checkpoints, gates, exits, scene-hook metadata, camera bounds, collision metadata, and a stage preview.
### Side-View Action / Platformer Stage
- `visual_model`: `parallax_layers` or `layered_tilemap` if the engine/editor already uses tiles
- `runtime_object_model`: `platform_objects + interactive_scene_objects + scene_hooks + foreground_occluders`
- `collision_model`: `precise_shapes` or engine-native platform/object collision
- Applies to Megaman-like, Castlevania-like, Contra-like, side-view action, runner, shooter, and brawler stages across pixel art, clean HD, and project-native styles.
- Required deliverables: shared `stage_canvas`, background/parallax art that matches that canvas, in-world stage reference mockup, separate platform or terrain-chunk sprites, hazard sprites, scene object placement data, scene-hook metadata, pickups/doors/checkpoints/gates when present, collision data, camera bounds, and a QA preview.
- The stage reference should plan no more than 9 distinct visible object candidates unless the user requests a larger pass. Use repeats in metadata rather than asking the image model to invent many unrelated props at once.
- Anti-pattern: one generated full-stage PNG plus collision rectangles. That is a background with hitboxes, not a playable stage.
## Escalation Heuristic
Start with the smallest playable bundle that works:
1. non-playable background: `baked_scene_mode`
2. beautiful top-down or tower-defense demo: `scene_mode`
3. editable top-down/platform/grid map: `tile_mode`
4. playable side-view scrolling/action stage: `side_scroll_mode`
5. tactical/factory/board rules-first scene: `grid_mode`
6. procedural/modular room assembly: `room_chunk_mode`
@@ -0,0 +1,24 @@
# Post-Reference Object Production Gate
An in-world reference mockup is never the final deliverable by itself. After generating `dressed-reference` or `stage-reference`, continue with:
1. Make both images visible in conversation context before any object/prop generation:
- the original `base` or `background`
- the generated `dressed-reference` or `stage-reference` mockup
2. If either image is a local file, call `read_file` on it immediately before writing object lists or object/prop image prompts. Do not rely on file paths alone.
3. Create a concrete object list from the visible reference mockup while cross-checking the original base/background: object id, type, approximate position, approximate size, render layer, collision role, and asset strategy.
- If the reference contains more than 9 distinct visible runtime object candidates, reduce the generated asset list to the 9 most gameplay-relevant candidates first, then represent extra repeats or low-value decorations through placement metadata or a later asset pass.
- Classify every object before generation. Compact decorative props may be batched; wide/long, tall/large, collision-bearing, and tileset/strip objects must use one-by-one, strip, custom wide pack, tile/object-layer, or engine-native strategies.
4. For each visible runtime object, choose exactly one asset strategy:
- generate a separate transparent asset with `$generate2dsprite` or direct `imagine_text_to_image`
- extract it from a generated prop/object pack
- represent it as a tile/object layer if the engine/editor pipeline is tile-based
5. For every object/prop generation that must match the map style, pass the base/background and/or reference mockup via `imagine_image_to_image` (`file_path`) or `imagine_reference_to_image` (path list for 2+) — and say in the prompt that the provided reference images are the visual context. The generated asset must match the original map style and correspond to an object visible in the reference mockup.
6. Generate or define the final platforms, terrain chunks, props, hazards, pickups, doors, gates, checkpoints, exits, foreground occluders, and other visible scene objects. Do not skip this step just because the reference mockup already contains them visually.
7. Write placement metadata such as `data/<name>-props.json`, `data/<name>-objects.json`, engine-native object layers, or tile/object data.
8. Write collision, zones, scene hooks, camera bounds, and exits as structured metadata.
9. Compose a QA preview from the original base/background plus final runtime objects.
Reference-only output is incomplete for any playable map, layered map with props, side-view stage, engine scene, or request that asks for separate props/editable objects. Only stop at a reference mockup if the user explicitly asks for a reference-only concept image.
For prop packs or object packs generated after a reference mockup, the prompt must be derived from the visible reference mockup and original base/background, not from memory or filenames. It should list the exact objects being generated and preserve the art style, lighting, perspective, and scale cues from the original base/background.
@@ -0,0 +1,149 @@
# Map Pipeline: parameters, workflow, and scripts
Read this once the `map_mode` is chosen and you are about to produce assets.
## Parameter Contract
User-facing parameters may be stated in natural language:
- `map_mode`: tile_mode | scene_mode | side_scroll_mode | grid_mode | room_chunk_mode | baked_scene_mode
- `map_kind`: overworld | town | dungeon | shrine | arena | battle_bg | side_scroller | side_view_action | platformer | metroidvania | brawler | tower_defense | survivors_like | tactical | factory | card_board | room_chunk
- `visual_model`: baked raster | layered raster | tilemap | layered tilemap | parallax
- `size`: pixel dimensions, tile dimensions, or camera-relative size
- `stage_canvas`: exact pixel dimensions and aspect ratio for side-scroll/parallax layers, references, and previews
- `perspective`: top-down | 3/4 top-down | side-view | isometric-like
- `art_style`: clean_hd | pixel_inspired | retro_pixel | hand_painted | project-native
- `visual_asset_source`: imagine_text_to_image | existing_assets | procedural_placeholder
- `collision_precision`: none | coarse | precise | tile | walkmesh
- `prop_generation`: none | one_by_one | prop_pack_2x2 | prop_pack_3x3 | prop_pack_4x4 | platform_strip_1x3 | platform_strip_1x4 | custom_wide_pack
- `output_format`: PNG only | layered preview | manifest JSON | engine-native map data
When unspecified:
- Use `imagine_text_to_image` as the visual asset source.
- Infer `map_mode` from genre and editing needs before selecting lower-level axes.
- Use `tile_mode` for Pokemon-like, top-down RPG, monster-taming, editor/grid-perfect, or tilemap requests.
- Use `scene_mode` for tower defense, survivors-like, cozy/top-down showcase maps, and base-map-plus-props requests.
- Use `side_scroll_mode` for side-scrollers, platformers, runners, side-view action, brawlers, Metroidvania side rooms, Mega Man-like, Castlevania-like, Contra-like, and parallax background requests.
- For `side_scroll_mode`, choose a canonical `stage_canvas` before image generation. Use the project camera/viewport aspect when available; otherwise default to a 16:9 side-scroller canvas such as `1536x864`. All primary parallax plates, stage references, and previews must preserve this same size/aspect.
- Use `grid_mode` for tactical RPGs, factory/automation maps, board/card battlers, build grids, and terrain-cost maps.
- Use `room_chunk_mode` for modular rooms, roguelike rooms, procedural room assembly, or Metroidvania room-chunk planning.
- Use `baked_scene_mode` only for non-playable visual scenes or explicitly flat images.
- Use `baked_raster + coarse_shapes` only for battle backgrounds, title/menu scenes, cutscenes, decorative backdrops, non-playable previews, or when the user explicitly asks for a single flat image.
- Use `layered_raster + y_sorted_props + precise_shapes` for top-down RPG exploration with tall props, occlusion, interactables, or reusable props; the base must be foundation-only and the props/interactables must remain separate.
- Use `tilemap` or `layered_tilemap` only when the engine/editor already uses tiles or the user asks for editable tiles; do not flatten gameplay objects into one background image.
- Use `parallax_layers + platform_objects + interactive_scene_objects + scene_hooks + precise_shapes` for playable side-view scrolling stages, platformers, runners, shooters, and horizontal action scenes; the parallax/background image is scenery-only and is not the runtime map by itself.
- Use square prop packs only when 4 or more compact small/medium static props share one style and fit comfortably inside equal square cells.
- Use one-by-one, platform strips, tile/object layers, or custom wide packs for hero props, buildings, gates, irregular large props, wide/tall props, platforms, terrain chunks, bridges, walls, ladders, long hazards, animated props, or props needing strong identity or collision alignment.
- Use `clean_hd` for generated exploration maps unless the project or user asks for pixel art. This means clean hand-painted top-down 2D RPG game map, HD game asset style, sharp readable terrain shapes, low texture noise, and no chunky pixels.
- Use `pixel_inspired` only when the user wants a pixel-adjacent look without retro chunkiness.
- Use `retro_pixel` only when the user explicitly asks for 16-bit, retro JRPG, or classic pixel-art maps.
## Workflow
1. Inspect the target game.
- Find camera size, map dimensions, coordinate system, render order, asset loading, collision support, zone data, and existing map formats.
- Preserve the engine's existing style and data contracts.
2. Choose the pipeline axes.
- Choose `map_mode` first. Use the genre routing table in `map-strategies.md` when the user describes a game type instead of a technical map format.
- Select `visual_model`, `runtime_object_model`, `collision_model`, and `engine_target`.
- If the request is for a playable map, stage, level, room, prototype, or game scene, choose a pipeline with explicit runtime objects. Do not downgrade to `baked_raster` unless the user asked for a background-only image.
- If the request implies a playable side-view scrolling/action stage, such as a side-scroller, platformer, runner, shooter, brawler, scrolling combat stage, Megaman-like stage, Castlevania-like stage, or Contra-like stage, lock the map pipeline to `parallax_layers + platform_objects + interactive_scene_objects + scene_hooks + precise_shapes` unless the engine already requires a tilemap.
- Select `art_style`. Prefer readable gameplay shapes over decorative texture density.
- Select `visual_asset_source`. Default to `imagine_text_to_image`; use `existing_assets` only when the project already has suitable art; use `procedural_placeholder` only when explicitly requested.
- Treat `hybrid` as a result of combining axes, not as a primary category.
3. Produce assets.
- Write the creative prompts manually and use built-in `imagine_text_to_image` for visible map art unless the user explicitly chose existing assets or procedural placeholders.
- For baked raster maps, generate one background with built-in `imagine_text_to_image`, or edit/use an existing image when supplied, then add optional collision/zones metadata.
- For playable or editable layered maps, generate a foundation-only base/background first. The base must not contain runtime-controlled props, interactables, hazards, doors, gates, pickups, actors, or foreground occluders. If it does, regenerate or demote it to a reference artifact.
- For layered raster maps, generate a ground-only/foundation-only base map first. Then perform the visual reference handoff and generate an in-world dressed reference mockup from the visible base before making final props and placements.
- For tilemaps, generate or reuse tileset art first, then follow the engine/editor format for layers, objects, collision, and scene files. Do not script-draw the tileset as the final art source, and do not flatten object layers into a single runtime image.
- For `grid_mode`, generate or reuse grid/tileset visual art first, then write cell metadata such as walkable/buildable flags, move cost, terrain effects, resource nodes, and object layers.
- For `room_chunk_mode`, define chunk dimensions, exits, connection sockets, collision contract, and spawn/trigger metadata before final art assembly. Chunks must be reusable and validated at their seams.
- For playable side-view scrolling/action stages, define the canonical `stage_canvas` before generating art. Generate named scenery-only parallax layers first: `sky`, `far_bg`, `mid_bg`, `near_bg`, and optional `foreground_overlay`. Every primary parallax layer must use the same pixel dimensions, aspect ratio, camera framing, horizon line, and top-left anchor as the `stage_canvas`; do not accept mismatched image sizes that require guesswork to stack. Do not treat one full-width background image as a complete `side_scroll_mode` background stack unless the user explicitly asks for a flat/non-parallax background. These parallax passes must not contain playable foreground platforms, walkable floors, terrain chunks, hazards, pickups, doors, gates, checkpoints, crates, fences, spikes, or other runtime objects. Then perform the visual reference handoff and generate an in-world stage reference mockup that visually places up to 9 distinct intended platform/object candidates before generating final separate scene objects and metadata.
- If a side-view background already contains collidable-looking foreground geometry, walkable floors, or reusable gameplay props, reject it as a runtime background and regenerate a cleaner scenery-only background before continuing.
- Treat the reference mockup as a checkpoint, not a deliverable. Do not stop after generating it. After the relevant `dressed-reference` or `stage-reference` exists, inspect it and continue into the post-reference object production gate (`object-production-gate.md`).
- Do not present a rerunnable script that creates the whole art pack as the main solution unless the user asked for procedural placeholder art.
4. Build metadata.
- Store prop placement, player spawns, actor spawn marker metadata, interactable scene objects, blockers, walk bounds, encounter zones, exits, camera bounds, and triggers as structured data.
- For `grid_mode`, store grid dimensions, cell size, tile ids, terrain types, walkable/buildable flags, movement cost, collision, resource nodes, and object/entity slots.
- For `room_chunk_mode`, store chunk id, size, entrances/exits, connection sockets, collision, spawn markers, camera bounds, and validation hints for seam alignment.
- For `side_scroll_mode`, store `stage_canvas`, parallax layer source size, display size, anchor, render order, scroll factors, loop/repeat policy, camera bounds, platform collision, hazards, exits, checkpoints, and actor spawn marker metadata.
- Keep collision independent from pixels unless the target engine explicitly uses tile collision.
5. Validate and preview.
- Compose a flattened preview for layered maps.
- Validate image sizes, alpha channels, prop pack extraction metadata, JSON parseability, and critical walkability points when collision matters.
- For `side_scroll_mode`, reject or normalize mismatched primary parallax layer sizes before runtime integration. The stage reference and QA preview must match `stage_canvas` exactly. Deterministic resizing/cropping/padding is allowed only as a normalization step on generated art, not as a way to invent missing art.
## Prop Generation Rules
Use `$generate2dsprite` for reusable transparent props and visible scene objects, but the agent must write the prop prompt itself using the selected map `art_style`. Do not use a script to generate the creative prompt. For `clean_hd` maps, explicitly request clean hand-painted HD 2D game assets and explicitly forbid pixel art. For `pixel_inspired`, request clean modern pixel-art-inspired props without retro chunkiness. For `retro_pixel`, request 16-bit or retro JRPG pixel art.
Before any prop/object image generation, classify each visible runtime object from the reference mockup:
- `compact_prop`: small/medium, roughly square or vertical, decorative or simple blocker, no exact alignment requirement
- `wide_or_long_object`: expected aspect ratio wider than about `1.6:1`, such as platforms, floor pieces, bridges, wall runs, fence rows, long traps, long signs, pipes, rails, ledges, or roads
- `tall_or_large_object`: expected aspect ratio taller than about `1.6:1` or visually dominant, such as large trees, gates, towers, buildings, banners, doors, statues, or boss-room props
- `collision_bearing_object`: must line up with collision, walkable edges, build pads, doors, checkpoints, gates, hazards, or engine editor handles
- `tileset_or_strip_piece`: should repeat seamlessly or assemble from left/middle/right caps, corners, slopes, tops, sides, or tile pieces
Generation strategy is determined by that classification:
- Only `compact_prop` objects may use square `prop_pack_2x2`, `prop_pack_3x3`, or `prop_pack_4x4`.
- Do not put `wide_or_long_object`, `tall_or_large_object`, `collision_bearing_object`, or `tileset_or_strip_piece` into square prop packs.
- Use `one_by_one` for important, large, tall, irregular, identity-sensitive, or collision-aligned objects.
- Use `platform_strip_1x3` or `platform_strip_1x4` for repeatable floors/platforms: left cap, middle repeat, right cap, plus optional corner/slope/end variant.
- Use `custom_wide_pack` only for several similar wide objects that share one category and can use wide cells such as `768x256`, `1024x384`, or another explicit non-square cell size.
- Never mix compact decorative props with platforms, terrain chunks, gates, doors, hazards, or other collision-critical objects in the same generated sheet.
- If a square pack fails because a wide/tall object touches an edge, do not retry the same square pack with looser QC. Reclassify that object and regenerate it one-by-one, as a platform strip, as a custom wide pack, or as tile/object-layer art.
Choose the generation shape deliberately:
- `one_by_one`: safest for large, important, animated, or irregular props.
- `prop_pack_2x2`: 4 related compact props, safest square batch size.
- `prop_pack_3x3`: 9 compact small/medium props, good quality/time tradeoff.
- `prop_pack_4x4`: 16 very simple compact small props; fastest but most likely to drift or touch edges.
- `platform_strip_1x3`: repeatable non-actor platform/floor strip with left cap, middle repeat, and right cap.
- `platform_strip_1x4`: repeatable non-actor platform/floor strip with left cap, middle repeat, right cap, and one extra slope/corner/end variant. This is not an animation-frame format and must not be used for characters, enemies, creatures, NPCs, summons, or animated body assets.
- `custom_wide_pack`: several related wide objects using explicit wide cells, not square cells.
Prop packs save image-generation calls and prompt overhead, but reduce per-prop control. Use square prop packs for rocks, shrubs, barrels, small signs, lamps, crates, floor ornaments, plants, and repeated compact environmental props. Do not use square prop packs for buildings, gates, trees with wide canopies, bridges, platforms, floors, walls, ladders, long fences, long hazards, character-like statues, hero objects, or anything that must be pixel-perfect or collision-aligned.
For layered maps with generated props, prefer this in-world reference mockup pipeline:
1. Generate `assets/map/<name>-base.png` as ground-only terrain.
2. Hand the base to `imagine_image_to_image` as a real reference: pass its sandbox `file_path`. Also `read_file` it so you can see it; do not rely on a path string or prompt text as the reference.
3. In the dressed-reference prompt, explicitly say: use the provided base image as the visual reference, preserve its camera/framing/dimensions/terrain/road/water/boundaries, and generate an in-world dressed reference mockup.
4. The dressed reference must show proposed props as natural game-world objects placed on the base. It must not contain circles, arrows, outlines, labels, text, callouts, legends, highlighted boxes, or other annotation graphics.
5. The dressed reference should contain at most 9 distinct visible prop/object candidates unless the user explicitly asks for more. Prefer the objects that will become final generated props, collision blockers, interactables, or occluders.
6. Generate `assets/map/<name>-dressed-reference.png` from the visible base. Treat this as a reference mockup, not the final runtime map.
7. Generate one-by-one props or a prop pack based on the dressed reference.
8. Place extracted props over the original base and compose a flattened preview.
9. Validate that base, dressed reference, and preview dimensions match.
## Scripts
Use the extract script after generating a solid-magenta prop sheet (built-in magenta cleanup is enough for most sheets):
```bash
python3 .grok/skills/generate2dmap/scripts/extract_prop_pack.py \
--input assets/props/raw/<name>-sheet.png \
--rows 3 --cols 3 \
--labels <comma-separated-labels> \
--output-dir assets/props \
--manifest assets/props/<name>-prop-pack.json \
--component-mode largest
```
Compose a QA preview:
```bash
python3 .grok/skills/generate2dmap/scripts/compose_layered_preview.py \
--base assets/map/<name>-base.png \
--placements data/<name>-props.json \
--output assets/map/<name>-layered-preview.png
```
@@ -0,0 +1,174 @@
# Prop Pack Contract
Prop packs batch multiple small static map props into one generated sheet, then extract each cell into a transparent prop PNG. Square prop packs are for compact props only, not for floors, platforms, bridges, walls, or other wide/collision-critical scene objects.
Use prop packs to reduce repeated image-generation calls and prompt overhead. They trade per-prop control for speed, so use them only when the props can share one style, scale, perspective, and quality bar.
## When To Use
Good candidates:
- rocks, shrubs, flowers, mushrooms, logs
- crates, barrels, sacks, pots
- small signs, lamps, lanterns, fences, posts
- floor ornaments, small statues, ruins, debris
- repeated environmental dressing for one biome
Avoid prop packs for:
- buildings, gates, trees with wide canopies, bridges
- floors, walkable platforms, terrain chunks, ledges, wall runs, rails, ladders, road segments, fence rows, long spike traps, pipes, conveyors, ramps, slopes, or any long horizontal object
- hero objects, key story artifacts, readable statues
- animated props or props with multiple states
- props requiring exact silhouette, scale, or identity
- props that are too wide/tall for equal square cells
- props that must line up exactly with collision, walkable edges, build pads, doorways, gate openings, checkpoints, hazards, exits, or engine editor handles
## Asset Strategy Gate
Classify every object before choosing a generation shape:
- `compact_prop`: small/medium, roughly square or vertical, decorative or simple blocker, no exact edge alignment requirement.
- `wide_or_long_object`: expected aspect ratio wider than about `1.6:1`, such as floors, platforms, bridges, ledges, wall runs, fence rows, long traps, rails, pipes, roads, conveyors, or long signs.
- `tall_or_large_object`: expected aspect ratio taller than about `1.6:1` or visually dominant, such as buildings, gates, large trees, towers, doors, banners, statues, or shrine pieces.
- `collision_bearing_object`: must align with collision, walkable edges, build pads, hazards, doors, gates, checkpoints, exits, or engine handles.
- `tileset_or_strip_piece`: should repeat or assemble from caps, middles, corners, slopes, tops, sides, or tile pieces.
Only `compact_prop` objects may use square `2x2`, `3x3`, or `4x4` prop packs. Everything else must use one-by-one generation, a strip/tileset workflow, custom wide cells, or engine-native tile/object layers.
Do not mix strategy classes in one sheet. A sheet of small rocks, crates, lamps, and grass is acceptable. A sheet that mixes rocks with platforms, floor pieces, gates, ladders, or spike hazards is not acceptable.
## Sheet Size Selection
- `2x2`: 4 props, safest batch size.
- `3x3`: 9 props, best default for compact small/medium environmental sets.
- `4x4`: 16 props, only for very simple small props with strong margins.
- `1x3 platform strip`: non-actor left cap, middle repeat, right cap for walkable floors/platforms.
- `1x4 platform strip`: non-actor left cap, middle repeat, right cap, plus one slope/corner/end variant. This is not an animation-frame format.
- `custom_wide_pack`: several related wide props using explicit wide cells such as `768x256`, `1024x384`, or another non-square cell size.
Use `3x3` by default only when the user asks for a set of compact map props and does not specify count.
Use one-by-one, platform strips, custom wide packs, or tile/object layers instead of a square pack for hero props, wide gates, buildings, wide trees, floors, platforms, bridges, walls, long hazards, ladders, or anything that must line up exactly with collision.
If a square pack fails because a wide/tall object touches the cell edge, do not pass it by relaxing QC and do not keep retrying the same square pack. Reclassify that object and regenerate it with one-by-one, a platform strip, a custom wide pack, or a tile/object-layer workflow.
## Prompt Pattern
For `3x3` and `4x4` packs, create a layout-only guide first with `$generate2dsprite`:
```bash
python3 .grok/skills/generate2dsprite/scripts/make_layout_guide.py \
--rows <ROWS> \
--cols <COLS> \
--cell-width 384 \
--cell-height 384 \
--output assets/props/raw/<name>-layout-guide.png
```
Pass the guide PNG's sandbox path to `imagine_image_to_image`. Tell the model to use it only for invisible slot count, spacing, centering, and safe padding. The output must not copy guide boxes, safe-area rectangles, center marks, labels, borders, or guide background.
```text
Create exactly one <ROWS>x<COLS> prop sheet for a top-down 2D RPG map.
Each cell contains one separate static environmental prop from this list, in row-major order:
1. <prop>
2. <prop>
...
All props share the same biome, palette, camera angle, selected map art style, and scale.
Use clean hand-painted HD 2D game asset style by default: crisp silhouettes, smooth surfaces, low texture noise, controlled accent lighting. Do not make pixel art unless the user asked for it.
Mostly front-facing top-down RPG object view: upright objects are vertical and centered, with only a small visible top face. Avoid strong isometric diagonal rotation; crates and barrels should not become diamond-shaped or tilted unless the user explicitly asks for isometric art.
Full object visible, centered in its own cell, crisp but not chunky outlines.
Each prop must fit fully inside the central 50% to 60% of its cell with generous flat magenta gutters on all four sides.
No prop, branch, roof, sign, glow, cable, smoke, sparkle, shadow, or fragment may touch or cross a cell edge.
This square prop sheet must contain only compact props. Do not include floors, platforms, bridges, wall runs, ladders, long hazards, gates, doors, buildings, wide trees, roads, ramps, slopes, or any object that needs exact collision or walkable-edge alignment.
Background must be 100% solid flat #FF00FF magenta in every cell, no gradients, no texture, no shadows, no floor plane.
No text, labels, UI, watermark, numbers, arrows, borders, grid lines, or readable letters.
```
If a cell should stay empty, explicitly say `empty magenta cell`.
## Platform Strip Prompt Pattern
For repeatable floors, platforms, bridges, or terrain chunks, do not use a square `3x3` prop pack. Use a strip or tileset-like atlas with wide cells and a layout guide:
```text
Create exactly one 1x3 platform strip asset sheet for a 2D game map.
Cells, left to right:
1. left end cap of the platform
2. seamless middle repeat segment
3. right end cap of the platform
Each cell is a wide non-square cell, intended for platform/floor collision alignment.
Every segment must have a perfectly horizontal walkable top edge at the same y-position across all cells.
The middle segment must tile seamlessly left-to-right.
No segment may touch or cross its cell edge except intentional seamless side edges on the middle repeat cell.
Use solid flat #FF00FF magenta background, no floor plane, no shadows, no labels, no UI, no guide lines.
```
Use `1x4` only for non-actor platform strips when a slope, corner, broken variant, or underside piece is needed. If a platform is unique, large, or very important, generate it one-by-one on a wide canvas instead of using a strip. Never use this format for characters, enemies, creatures, NPCs, summons, or animated body assets.
## Extraction
The extract script includes built-in solid-magenta chroma cleanup. Prefer a
hard-key first; if fringe remains after visual QC, regenerate the sheet with a
flatter `#FF00FF` background or tighten thresholds via the script flags.
```bash
python3 .grok/skills/generate2dmap/scripts/extract_prop_pack.py \
--input assets/props/raw/forest-props-sheet.png \
--rows 3 \
--cols 3 \
--labels mossy-rock,shrub,fallen-log,small-lantern,wooden-sign,flower-patch,stump,crate,grass-tuft \
--output-dir assets/props \
--manifest assets/props/forest-prop-pack.json \
--component-mode largest \
--component-padding 8 \
--min-component-area 200 \
--reject-edge-touch
```
Output shape:
```text
assets/props/<label>/prop.png
assets/props/forest-prop-pack.json
```
The manifest contains source cell coordinates, crop boxes, alpha bounds, extracted image size, component counts, and `edge_touch` flags.
If the first pack fails because large props touch cell edges, regenerate with stricter occupancy wording such as `each prop must fit inside the central 50% of its cell`. Do not pass a failed pack by relaxing QC unless the clipped asset is intentionally discarded.
## Placement
After extraction, create placement JSON:
```json
{
"props": [
{
"id": "mossy-rock-1",
"image": "assets/props/mossy-rock/prop.png",
"x": 420,
"y": 512,
"w": 96,
"h": 72,
"sortY": 512,
"layer": "props"
}
]
}
```
Then compose a QA preview with `scripts/compose_layered_preview.py`.
## QC Rules
Reject or regenerate the pack when:
- any accepted prop has `edge_touch: true`
- labels do not match the requested cells
- a prop has text, UI, shadows, or floor baked in
- prop identity drifts into character/NPC-like art
- a prop is too large for the intended placement scale
- a square pack contains a wide/long, tall/large, collision-bearing, platform, floor, bridge, wall, ladder, gate, door, or tileset/strip object
For noisy particles or edge debris, reprocess with `--component-mode largest`. For intentional multi-part props, use `--component-mode all` and increase the prompt margin.
@@ -0,0 +1,29 @@
# Playable Stage Reference Rules
For playable side-view scrolling/action maps, an in-world stage reference mockup is mandatory before generating final scene objects or scene metadata. This applies across art styles and game styles, including pixel art, clean HD, side-scrollers, platformers, runners, shooters, brawlers, scrolling combat stages, and Megaman-like or Castlevania-like stages:
0. Choose and record one `stage_canvas`, for example `1536x864` for a default 16:9 HD side-scroller when the project has no explicit camera size. Use the engine's existing viewport aspect ratio when it exists. All primary parallax layers, the stage reference, and the stage preview must share this exact size unless a layer is explicitly marked as a repeatable strip.
1. Generate named parallax scenery layers as separate runtime images: `assets/map/<name>-sky.png`, `assets/map/<name>-far-bg.png`, `assets/map/<name>-mid-bg.png`, `assets/map/<name>-near-bg.png`, and optional `assets/map/<name>-foreground-overlay.png`.
- These layers are scenery only, not playable foreground. They may contain sky, clouds, mountains, distant buildings, distant castle walls, silhouettes, atmosphere, and non-colliding far depth.
- Do not collapse these layers into only `assets/map/<name>-background.png` for a playable `side_scroll_mode` stage. A single scenery background is allowed only when the user explicitly requests a flat/non-parallax background; in that case still continue with stage reference, separate objects, collision, camera bounds, and QA preview.
- Each primary layer prompt must specify the same target canvas size/aspect ratio, same camera framing, same horizon height, and same top-left aligned composition. If image generation returns different sizes, regenerate or normalize them to `stage_canvas` before using them together.
- Repeatable strips and foreground/object sprites may have different source dimensions, but they must declare display size, anchor point, repeat axis, and scale in metadata. They are not substitutes for the primary parallax plates.
- It must not contain walkable floors, platform tops, terrain chunks, spike traps, pickups, crates, doors, gates, checkpoints, ladders, near fences, near stone walls, enemies, player characters, UI, labels, or any object that should later be edited, collided with, reused, or layered independently.
- Keep the playable foreground lane visually open or neutral so separate platform/object layers can stack clearly over it.
2. Hand the background to `imagine_image_to_image` as a real reference: pass its sandbox `file_path`. Also `read_file` it so you can see it; do not rely on a path string or prompt text as the reference.
3. In the stage-reference prompt, explicitly say: use the provided background image as the visual reference, preserve exact camera/framing/dimensions/horizon/depth/entrances/exit direction, and generate an in-world stage reference mockup.
4. Generate `assets/map/<name>-stage-reference.png` from the visible background.
5. In the stage reference, visually place the intended scene layout as natural game-world objects or subtle blockout geometry: platforms or walkable lanes, terrain chunks, foreground occluders, hazards, pickups, doors, checkpoints, gates, and exits.
- Use at most 9 distinct visible runtime object candidates in the stage reference unless the user explicitly asks for a larger object pass. Repeated placements of the same platform, terrain chunk, hazard, pickup, checkpoint, door, gate, or occluder count as one candidate and should be repeated later in metadata.
- Prioritize objects that the final game must render or collide with separately. Avoid filling the mockup with many small decorative foreground props that will not become reusable assets.
6. Do not draw spawn markers, actor markers, arena trigger zones, camera bounds, arrows, labels, circles, outlines, numbered callouts, text, legends, or UI overlays in the reference image. Record player spawn, actor spawn markers, arena triggers, camera bounds, and exit links later as scene-hook metadata.
7. Use the stage reference to decide object identities, sizes, coordinates, render order, collision shapes, and camera bounds.
8. Continue through the post-reference object production gate: generate or define final platforms, terrain chunks, hazards, pickups, doors, checkpoints, foreground occluders, and other visible scene objects as separate assets, tile layers, or object layers. Compose the final runtime preview from the original background plus these separate runtime objects.
The stage reference is an in-world reference mockup. Do not ship it as the runtime map, do not infer collision from its pixels, and do not cut platform objects out of the baked reference image. If a platform must be reusable or collidable, generate it as a separate platform object, terrain chunk, tile, or engine-native object.
If the generated background already has obvious foreground gameplay pieces baked into it, do not use it as `background` in runtime data. Regenerate the scenery-only background or demote that image to a concept/reference artifact.
Scene hooks are metadata only. Do not generate enemy, boss, NPC, player, projectile, or animation sprites inside `generate2dmap`; call `$generate2dsprite` separately when the game needs those assets.
If a playable side-view scrolling/action run has already generated a background but has not generated `assets/map/<name>-stage-reference.png`, pause the platform/props pipeline and generate the stage reference next. Background plus props is not enough evidence that the level layout is coherent.
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Compose a flattened layered-map preview from a base image and prop placements."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from PIL import Image
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def resolve_path(value: str, roots: list[Path]) -> Path:
path = Path(value)
if path.is_absolute():
return path
for root in roots:
candidate = root / path
if candidate.exists():
return candidate
return roots[0] / path
def load_props(data: Any) -> list[dict[str, Any]]:
"""Load placement entries from a list or object with layer lists.
When the payload is an object, merge *all* of props / foreground / objects
(in that order) so layered QA previews do not drop platforms or occluders
just because another key appeared first.
"""
if isinstance(data, list):
return data
if isinstance(data, dict):
merged: list[dict[str, Any]] = []
for key in ("props", "foreground", "objects"):
value = data.get(key)
if not isinstance(value, list):
continue
if key == "foreground":
merged.extend({**item, "layer": "foreground"} for item in value)
else:
merged.extend(value)
if merged:
return merged
raise ValueError(
"Placement JSON must be a list or an object with a 'props', "
"'foreground', and/or 'objects' list."
)
def placement_xy(prop: dict[str, Any], width: int, height: int) -> tuple[int, int]:
anchor = str(prop.get("anchor", "center-bottom"))
x = float(prop.get("x", 0))
y = float(prop.get("y", 0))
if anchor == "top-left":
left = x
top = y
elif anchor == "center":
left = x - width / 2
top = y - height / 2
elif anchor == "bottom-left":
left = x
top = y - height
else:
left = x - width / 2
top = y - height
return round(left), round(top)
def paste_prop(canvas: Image.Image, prop: dict[str, Any], roots: list[Path]) -> dict[str, Any]:
image_key = prop.get("image") or prop.get("path")
if not image_key:
raise ValueError(f"Prop is missing image/path: {prop}")
image_path = resolve_path(str(image_key), roots)
if not image_path.exists():
raise FileNotFoundError(f"Prop image not found: {image_path}")
img = Image.open(image_path).convert("RGBA")
width = int(prop.get("w", prop.get("width", img.width)))
height = int(prop.get("h", prop.get("height", img.height)))
if width <= 0 or height <= 0:
raise ValueError(f"Invalid prop size for {image_path}: {width}x{height}")
if (width, height) != img.size:
img = img.resize((width, height), Image.Resampling.LANCZOS)
opacity = float(prop.get("opacity", 1.0))
if opacity < 1:
alpha = img.getchannel("A").point(lambda value: int(value * max(0.0, min(1.0, opacity))))
img.putalpha(alpha)
left, top = placement_xy(prop, width, height)
canvas.alpha_composite(img, (left, top))
return {
"id": prop.get("id", image_path.stem),
"image": str(image_path),
"left": left,
"top": top,
"w": width,
"h": height,
"sortY": prop.get("sortY", prop.get("y", top + height)),
"layer": prop.get("layer", "props"),
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True, type=Path)
parser.add_argument("--placements", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--report", type=Path)
parser.add_argument("--project-root", type=Path, default=Path.cwd())
return parser
def main() -> None:
args = build_parser().parse_args()
base = Image.open(args.base).convert("RGBA")
data = read_json(args.placements)
props = load_props(data)
roots = [args.placements.parent, args.base.parent, args.project_root]
props_layer = [prop for prop in props if str(prop.get("layer", "props")) != "foreground"]
foreground_layer = [prop for prop in props if str(prop.get("layer", "props")) == "foreground"]
props_layer.sort(key=lambda item: float(item.get("sortY", item.get("y", 0))))
foreground_layer.sort(key=lambda item: float(item.get("sortY", item.get("y", 0))))
pasted = []
for prop in props_layer + foreground_layer:
pasted.append(paste_prop(base, prop, roots))
args.output.parent.mkdir(parents=True, exist_ok=True)
base.save(args.output)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
json.dumps(
{
"base": str(args.base),
"placements": str(args.placements),
"output": str(args.output),
"pasted": pasted,
},
indent=2,
),
encoding="utf-8",
)
print(str(args.output.resolve()))
if __name__ == "__main__":
main()
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""Extract transparent map props from a solid-magenta prop-pack sheet."""
from __future__ import annotations
import argparse
import json
import math
import re
from collections import deque
from pathlib import Path
from typing import Iterable
from PIL import Image
MAGENTA = (255, 0, 255)
def color_distance(rgb: tuple[int, int, int], target: tuple[int, int, int] = MAGENTA) -> float:
r, g, b = rgb
tr, tg, tb = target
return math.sqrt((r - tr) ** 2 + (g - tg) ** 2 + (b - tb) ** 2)
def remove_bg_magenta(img: Image.Image, threshold: int, edge_threshold: int) -> Image.Image:
img = img.convert("RGBA")
pixels = img.load()
width, height = img.size
for x in range(width):
for y in range(height):
r, g, b, a = pixels[x, y]
if a > 0 and color_distance((r, g, b)) < threshold:
pixels[x, y] = (0, 0, 0, 0)
visited: set[tuple[int, int]] = set()
queue: deque[tuple[int, int]] = deque()
for x in range(width):
queue.append((x, 0))
queue.append((x, height - 1))
for y in range(height):
queue.append((0, y))
queue.append((width - 1, y))
while queue:
x, y = queue.popleft()
if (x, y) in visited or x < 0 or x >= width or y < 0 or y >= height:
continue
visited.add((x, y))
r, g, b, a = pixels[x, y]
should_expand = a == 0
if a > 0 and color_distance((r, g, b)) < edge_threshold:
pixels[x, y] = (0, 0, 0, 0)
should_expand = True
if should_expand:
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx == 0 and dy == 0:
continue
nxt = (x + dx, y + dy)
if nxt not in visited:
queue.append(nxt)
return img
def trim_border(img: Image.Image, px: int) -> Image.Image:
if px <= 0:
return img
width, height = img.size
if width <= px * 2 or height <= px * 2:
return img
return img.crop((px, px, width - px, height - px))
def clean_edges(img: Image.Image, depth: int) -> Image.Image:
if depth <= 0:
return img
pixels = img.load()
width, height = img.size
for d in range(depth):
for x in range(width):
for y in (d, height - 1 - d):
if 0 <= y < height:
r, g, b, a = pixels[x, y]
if a > 0 and (
(r < 40 and g < 40 and b < 40) or color_distance((r, g, b)) < 150
):
pixels[x, y] = (0, 0, 0, 0)
for y in range(height):
for x in (d, width - 1 - d):
if 0 <= x < width:
r, g, b, a = pixels[x, y]
if a > 0 and (
(r < 40 and g < 40 and b < 40) or color_distance((r, g, b)) < 150
):
pixels[x, y] = (0, 0, 0, 0)
return img
def connected_components(img: Image.Image, min_area: int) -> list[dict[str, object]]:
alpha = img.getchannel("A")
pixels = alpha.load()
width, height = img.size
visited = [[False] * width for _ in range(height)]
components: list[dict[str, object]] = []
for y in range(height):
for x in range(width):
if pixels[x, y] == 0 or visited[y][x]:
continue
queue: deque[tuple[int, int]] = deque([(x, y)])
visited[y][x] = True
coords: list[tuple[int, int]] = []
min_x = max_x = x
min_y = max_y = y
touches_edge = x == 0 or y == 0 or x == width - 1 or y == height - 1
while queue:
cx, cy = queue.popleft()
coords.append((cx, cy))
min_x = min(min_x, cx)
min_y = min(min_y, cy)
max_x = max(max_x, cx)
max_y = max(max_y, cy)
if cx == 0 or cy == 0 or cx == width - 1 or cy == height - 1:
touches_edge = True
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nx, ny = cx + dx, cy + dy
if (
0 <= nx < width
and 0 <= ny < height
and pixels[nx, ny] > 0
and not visited[ny][nx]
):
visited[ny][nx] = True
queue.append((nx, ny))
if len(coords) >= min_area:
components.append(
{
"area": len(coords),
"bbox": (min_x, min_y, max_x + 1, max_y + 1),
"touches_edge": touches_edge,
"coords": coords,
}
)
components.sort(key=lambda item: int(item["area"]), reverse=True)
return components
def pad_bbox(
bbox: tuple[int, int, int, int], padding: int, width: int, height: int
) -> tuple[int, int, int, int]:
x0, y0, x1, y1 = bbox
return (
max(0, x0 - padding),
max(0, y0 - padding),
min(width, x1 + padding),
min(height, y1 + padding),
)
def bbox_touches_edge(
bbox: tuple[int, int, int, int] | None,
width: int,
height: int,
margin: int,
) -> bool:
if bbox is None:
return False
x0, y0, x1, y1 = bbox
return x0 <= margin or y0 <= margin or x1 >= width - margin or y1 >= height - margin
def sanitize_slug(value: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
return slug or "prop"
def parse_labels(args: argparse.Namespace, expected_count: int) -> list[str]:
labels: list[str] = []
if args.labels:
labels = [item.strip() for item in args.labels.split(",")]
if args.labels_file:
labels = [
line.strip()
for line in args.labels_file.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
if not labels:
labels = [f"prop-{index + 1}" for index in range(expected_count)]
if len(labels) > expected_count:
raise ValueError(f"Got {len(labels)} labels for {expected_count} cells.")
labels.extend(f"prop-{index + 1}" for index in range(len(labels), expected_count))
return [
sanitize_slug(label) if label.lower() not in {"empty", "skip", "-"} else ""
for label in labels
]
def alpha_bbox(img: Image.Image) -> tuple[int, int, int, int] | None:
return img.getchannel("A").getbbox()
def mask_to_component(img: Image.Image, component: dict[str, object]) -> Image.Image:
selected = Image.new("RGBA", img.size, (0, 0, 0, 0))
src = img.load()
dst = selected.load()
for x, y in component["coords"]: # type: ignore[index]
dst[x, y] = src[x, y]
return selected
def extract_cell(
cell: Image.Image,
args: argparse.Namespace,
) -> tuple[Image.Image | None, dict[str, object]]:
frame = trim_border(cell, args.trim_border)
frame = clean_edges(frame, args.edge_clean_depth)
components = connected_components(frame, args.min_component_area)
selected_component = None
bbox = alpha_bbox(frame)
if args.component_mode == "largest" and components:
selected_component = components[0]
frame = mask_to_component(frame, selected_component)
bbox = tuple(selected_component["bbox"]) # type: ignore[arg-type]
elif components:
bbox = alpha_bbox(frame)
padded_bbox = (
pad_bbox(bbox, args.component_padding, frame.width, frame.height) if bbox else None
)
edge_touch = bbox_touches_edge(bbox, frame.width, frame.height, args.edge_touch_margin)
prop = frame.crop(padded_bbox) if padded_bbox else None
return prop, {
"component_mode": args.component_mode,
"component_count": len(components),
"selected_component_area": int(selected_component["area"]) if selected_component else None,
"selected_component_bbox": list(selected_component["bbox"]) if selected_component else None,
"crop_bbox": list(bbox) if bbox else None,
"padded_crop_bbox": list(padded_bbox) if padded_bbox else None,
"edge_touch": edge_touch,
"output_size": list(prop.size) if prop else [0, 0],
}
def iter_cells(
img: Image.Image, rows: int, cols: int
) -> Iterable[tuple[int, int, tuple[int, int, int, int], Image.Image]]:
width, height = img.size
cell_width = width // cols
cell_height = height // rows
for row in range(rows):
for col in range(cols):
box = (
col * cell_width,
row * cell_height,
(col + 1) * cell_width,
(row + 1) * cell_height,
)
yield row, col, box, img.crop(box)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--rows", required=True, type=int)
parser.add_argument("--cols", required=True, type=int)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--manifest", type=Path)
parser.add_argument("--labels", help="Comma-separated labels in row-major order.")
parser.add_argument("--labels-file", type=Path)
parser.add_argument("--threshold", type=int, default=100)
parser.add_argument("--edge-threshold", type=int, default=150)
parser.add_argument("--trim-border", type=int, default=4)
parser.add_argument("--edge-clean-depth", type=int, default=2)
parser.add_argument("--component-mode", choices=["all", "largest"], default="largest")
parser.add_argument("--component-padding", type=int, default=8)
parser.add_argument("--min-component-area", type=int, default=100)
parser.add_argument("--edge-touch-margin", type=int, default=0)
parser.add_argument("--reject-edge-touch", action="store_true")
parser.add_argument("--keep-empty", action="store_true")
return parser
def main() -> None:
args = build_parser().parse_args()
expected_count = args.rows * args.cols
labels = parse_labels(args, expected_count)
args.output_dir.mkdir(parents=True, exist_ok=True)
raw = Image.open(args.input).convert("RGBA")
cleaned = remove_bg_magenta(raw, args.threshold, args.edge_threshold)
manifest_path = args.manifest or (args.output_dir / "prop-pack.json")
accepted: list[dict[str, object]] = []
rejected: list[dict[str, object]] = []
for index, (row, col, source_box, cell) in enumerate(iter_cells(cleaned, args.rows, args.cols)):
label = labels[index]
cell_info: dict[str, object] = {
"index": index,
"label": label,
"grid": [row, col],
"source_box": list(source_box),
}
if not label:
cell_info["status"] = "skipped-label"
rejected.append(cell_info)
continue
prop, info = extract_cell(cell, args)
cell_info.update(info)
if prop is None:
cell_info["status"] = "empty"
if args.keep_empty:
prop = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
else:
rejected.append(cell_info)
continue
# With --reject-edge-touch, never write edge-touching props as accepted.
if args.reject_edge_touch and bool(cell_info.get("edge_touch")):
cell_info["status"] = "edge_touch"
rejected.append(cell_info)
continue
prop_dir = args.output_dir / label
prop_dir.mkdir(parents=True, exist_ok=True)
prop_path = prop_dir / "prop.png"
prop.save(prop_path)
cell_info["status"] = "accepted"
cell_info["image"] = str(prop_path)
accepted.append(cell_info)
edge_touch_props = [
item["label"]
for item in accepted + rejected
if bool(item.get("edge_touch")) and item.get("status") in {"accepted", "edge_touch"}
]
manifest = {
"input": str(args.input),
"rows": args.rows,
"cols": args.cols,
"threshold": args.threshold,
"edge_threshold": args.edge_threshold,
"component_mode": args.component_mode,
"component_padding": args.component_padding,
"min_component_area": args.min_component_area,
"edge_touch_margin": args.edge_touch_margin,
"accepted": accepted,
"rejected": rejected,
"edge_touch_props": edge_touch_props,
}
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
if args.reject_edge_touch and edge_touch_props:
raise ValueError(f"Props touch a cell edge (not accepted): {edge_touch_props}")
print(str(manifest_path.resolve()))
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 0x0funky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+313
View File
@@ -0,0 +1,313 @@
---
name: generate2dsprite
description: >
Generate and postprocess 2D game sprites and animation sheets: pixel-art
characters, NPCs, creatures, spells, projectiles, impacts, props, summons,
and transparent PNG/GIF exports. Use when building browser games that need
real sprite sheets (not code-drawn placeholders), matching a map art style,
or producing magenta-background sheets for chroma-key cleanup. Triggers on
"sprite", "sprite sheet", "animation sheet", "pixel art character", "walk
cycle", "attack animation", "projectile sprite", "2D game asset".
metadata:
short-description: "2D sprite sheets: imagine_text_to_image + magenta chroma postprocess"
user-invocable: false
---
# Generate2dsprite
Use this skill for self-contained 2D sprite or animation assets in the
**app-builder sandbox** (TanStack Start + browser games).
When a larger game or playable prototype needs sprites, use this skill for the
visible sprite assets and keep runtime/game assembly separate (wire into Phaser /
Canvas / DOM after export). Do not replace requested sprite assets with
code-drawn placeholders.
## App-builder / Grok environment
| Item | Value |
| --- | --- |
| Skill dir | `.grok/skills/generate2dsprite/` |
| Scripts | `python3 .grok/skills/generate2dsprite/scripts/<script>.py …` |
| Image tools | `imagine_text_to_image` / `imagine_image_to_image` (path-based; see **`imagine`** skill for prompt craft) |
| Inspect images | `read_file` on the PNG path (not Codex view_image) |
| Generated image path | `imagine_text_to_image` returns a sandbox `file_path`; copy that path into your run dir before processing |
| Python deps | Pillow + numpy (preinstalled in the image) |
| Output home | Prefer `assets/sprites/<name>/` under `/workspace` so the app can import them |
| Engine target | Browser: Canvas 2D, Phaser, or DOM/`<img>` — not Godot/Unity unless the user asks |
Related skills: **`imagine`** (image tool usage), **`game-asset-core`** (+
`game-animation-frames` / `game-character-consistency` for QC and engine-ready
defaults), **`generate2dmap`** (maps/props), **`video2dsprite`** (denser motion
via `imagine_image_to_video`), **`building-games`** (game loop / integration).
## Parameters
Infer these from the user request:
- `asset_type`: `player` | `npc` | `creature` | `character` | `spell` | `projectile` | `impact` | `prop` | `summon` | `fx`
- `action`: `single` | `idle` | `cast` | `attack` | `shoot` | `jump` | `hurt` | `combat` | `walk` | `run` | `hover` | `charge` | `projectile` | `impact` | `explode` | `death`
- `view`: `topdown` | `side` | `3/4`
- `sheet`: `auto` | `2x2` | `2x3` | `2x4` | `3x3` | `3x4` | `4x4` | `5x5` | `custom_grid` | `strip_1x3` | `strip_1x4`
- `frames`: `auto` or explicit count
- `bundle`: `single_asset` | `unit_bundle` | `spell_bundle` | `combat_bundle` | `line_bundle` | `hero_action_bundle` | `engine_atlas`
- `effect_policy`: `all` | `largest`
- `anchor`: `center` | `bottom` | `feet`
- `margin`: `tight` | `normal` | `safe`
- `art_style`: pixel_art | clean_hd | pixel_inspired | retro_pixel | map_style | project-native
- `reference`: `none` | `attached_image` | `generated_image` | `local_file`
- `layout_guide`: `none` | `optional` | `recommended`
- `prompt`: the user's theme or visual direction
- `role`: only when the asset is clearly an NPC role
- `name`: optional output slug
Read [references/modes.md](references/modes.md) when the request is ambiguous.
## Agent Rules
- Decide the asset plan yourself. Do not force the user to spell out sheet size, frame count, or bundle structure when the request already implies them.
- Do not pack unrelated actions into one raw generated sheet just to satisfy a `4x4`, `5x5`, or custom engine atlas. A raw generated sheet should represent one action family, one continuous sequence, one canonical directional locomotion sheet, or one prop/asset pack.
- For controllable heroes, main characters, and high-value player assets with multiple actions, generate separate per-action grid sheets first, QC each action, then deterministically assemble the engine-required atlas only after the grids pass visual review.
- For controllable heroes, main characters, and high-value player body actions, default attack/shoot/cast body sheets to body-only. Do not include large slash arcs, muzzle flashes, projectiles, impact bursts, detached dust, long trails, or wide detached FX in the body sheet. Generate those as separate `fx`, `projectile`, or `impact` sheets and layer them in the game.
- Only include wide attack FX in the same raw body sheet when the target runtime explicitly supports wider per-action cells plus per-action origin/anchor metadata. Otherwise, a wide FX bbox will force the body to shrink inside the fixed cell.
- Write the art prompt yourself. Do not default to the prompt-builder script.
- Use built-in `imagine_text_to_image` for every raw image.
- Do not create raw sprite art with Three.js, Canvas, SVG, HTML/CSS drawing, PIL shape drawing, procedural geometry, placeholder primitives, or code-rendered screenshots. Runtime code may display finished generated assets, and scripts may make layout guides or postprocess generated images, but requested sprite art must originate from built-in `imagine_text_to_image`.
- When the user provides or implies a visual reference, pass that reference's sandbox `file_path` to `imagine_image_to_image` (the tool reads the file). Also `read_file` the local reference so you can see it; a path mentioned only inside the prompt is not a visual input.
- Do not force pixel art when the asset is a map prop for `$generate2dmap` or when the user/project requests a different style. Match the map or reference style first.
- Use the script only as a deterministic processor: magenta cleanup, frame splitting, component filtering, scaling, alignment, QC metadata, transparent sheet export, and GIF export.
- Do not use scripts to generate the creative image prompt. If a legacy prompt-builder command exists, treat it as historical compatibility only, not the normal skill workflow.
- Layout guides are allowed only as deterministic geometry references for image generation. They may show slot count, spacing, centering, and safe padding, but must never define the creative art direction.
- Treat script flags as execution primitives chosen by the agent, not user-facing hardcoded workflow.
- If a generated sheet touches cell edges, drifts in scale, or breaks a projectile / impact loop, either reprocess with better primitive settings or regenerate the raw sheet.
- Do not use raw single-row sheets such as `1x4`, `1x6`, `1x8`, or `1xN` for characters, players, controllable heroes, creatures, NPCs, enemies, summons, animated props, or any asset where a body/subject must stay centered. Single-row raw generation is too likely to drift horizontally and crop inconsistently.
- For animated body assets, use a multi-row grid by default: 4 frames -> `2x2`, 6 frames -> `2x3`, 8 frames -> `2x4`, 9 frames -> `3x3`, 12 frames -> `3x4` or `4x3`, 16 frames -> `4x4`.
- If a game engine needs a final single-row strip or mixed atlas, first generate and QC the action as a multi-row grid, then assemble the delivery strip/atlas deterministically.
- In every animated body grid prompt, require the subject body to stay centered in each cell, full body inside the central 60% to 70% safe area, consistent scale across cells, stable feet/bottom anchor line when applicable, and no limbs, weapons, hair, capes, dust, muzzle flashes, or detached FX crossing cell edges.
- For hero attack body prompts, explicitly require body height and body scale to match the accepted idle/run sheets, stable feet/bottom anchor, weapon kept close enough to avoid widening the body bbox, and no detached slash arc or screen-space attack effect.
- For map prop packs, classify props before choosing a grid. Square `2x2`, `3x3`, and `4x4` packs are only for compact props. Do not put platforms, floors, bridges, walls, ladders, gates, doors, long hazards, wide/tall props, collision-bearing objects, or tileset/strip pieces into square prop packs; use one-by-one, `1x3`/`1x4` strips, custom wide cells, or a tileset-like atlas instead.
- Keep the solid `#FF00FF` background rule unless the user explicitly wants a different processing workflow.
## Workflow
### 1. Infer the asset plan
Pick the smallest useful output.
Examples:
- controllable hero with four directions -> `player` + `player_sheet`
- side-view controllable hero with idle/run/shoot/jump -> `player` + `hero_action_bundle`
- idle grid sheet, usually `2x2` for 4 frames
- run grid sheet, usually `2x2` or `2x3` depending on needed frame count
- shoot grid sheet with body/weapon only, usually `2x2`
- jump grid sheet, usually `2x2`
- projectile / muzzle flash as separate assets when needed
- optional assembled engine atlas after per-action QC
- side-view controllable hero with melee attack -> `player` + `hero_action_bundle`
- attack body grid sheet, usually `2x2` or `2x3`, body-only
- slash arc / weapon trail as a separate `fx` sheet when the attack needs a wide visual effect
- impact spark as a separate `impact` sheet when hits need feedback
- healer overworld NPC -> `npc` + `single_asset` or `unit_bundle`
- large boss idle loop -> `creature` + `idle` + `3x3`
- wizard throwing a magic orb -> `spell_bundle`
- caster cast sheet
- projectile loop
- impact burst
- monster line request -> `line_bundle`
- plan 1-3 forms
- per form, make the sheets the request actually needs
### 2. Write the prompt manually
Use [references/prompt-rules.md](references/prompt-rules.md).
Choose `art_style` before writing the prompt:
- Use `pixel_art` or `retro_pixel` for classic sprites, 16-bit RPG actors, and requests that explicitly ask for pixel art.
- Use `clean_hd` for map props or assets intended to match clean hand-painted HD maps.
- Use `pixel_inspired` only when the user wants a pixel-adjacent look without retro chunkiness.
- Use `map_style` or `project-native` when an existing map, game, or reference should define the style.
If a reference is involved:
- Wire the reference into the call: pass its sandbox `file_path` to `imagine_image_to_image` — or the path list to `imagine_reference_to_image` for 2+ refs (generated images already have a `file_path`; local files use their sandbox path). Also `read_file` local references so you can see them.
- State the reference role explicitly: preserve identity/style, create an animation sheet for the same subject, create an evolution/variant, or derive a matching prop/FX.
- Preserve the stable identity markers from the reference: silhouette, palette, face/eye features, costume marks, major accessories, and material language.
- Let only the requested action or evolution change. Do not redesign the subject unless the user asks.
- Still require exact sheet shape, solid magenta background, frame containment, and same scale across frames.
Keep the strict parts:
- solid `#FF00FF` background
- exact sheet shape
- same character or asset identity across frames
- same bounding box and pixel scale across frames
- explicit containment: nothing may cross cell edges
Mixed-action atlas guardrail:
- Do not ask `imagine_text_to_image` to generate unrelated action rows in one raw sheet, such as `row 1 idle, row 2 run, row 3 shoot, row 4 jump`, for a controllable hero or main character.
- Do not ask `imagine_text_to_image` to generate raw single-row action strips such as `1x4 idle`, `1x4 run`, `1x4 shoot`, or `1x4 jump` for a controllable hero, character, creature, NPC, enemy, summon, or animated prop.
- If an engine needs a combined `4x4`, `5x5`, custom atlas, or row-strip delivery format, generate the action grids separately, process and QC them separately, then assemble the delivery atlas deterministically.
- Exceptions are canonical directional locomotion sheets, one continuous long action sequence, prop packs, tileset-like atlases, and low-stakes compact enemy combat sheets. These still need one coherent prompt and visual QC.
- Keep projectile, muzzle flash, impact, dust trails, and detached FX in separate sheets unless they are intentionally part of the same action silhouette and remain tightly attached.
- For controllable heroes and main characters, "tightly attached" is not enough when the effect makes the action bbox much wider or taller than idle/run. Split wide slash arcs, muzzle flashes, long weapon trails, dust clouds, and impact bursts into separate FX sheets by default.
Animated body grid guardrail:
- `1x4` and other raw single-row sheets are not valid defaults for animated bodies. This includes players, controllable heroes, creatures, NPCs, enemies, summons, animated props, and body-attached combat actions.
- Use `2x2` for 4-frame body actions. This is the default for idle, short attack, shoot body, jump, hurt, hover, and compact side-view walk/run actions.
- Use `2x3` for 6-frame body actions such as cast, attack, summon, run, charge, or transformation.
- Use `2x4`, `3x3`, `3x4`, or `4x4` for longer body actions. Prefer a compact grid over a long row.
- For 4-direction top-down walk, `4x4` can remain a raw generation shape because it is a canonical directional locomotion sheet, not four unrelated action rows.
- If final runtime needs a row strip, assemble it after QC from the processed multi-row grid frames.
- Keep the character centered in every cell. The body centerline should stay near the cell center, feet/bottom anchor should stay on the same y-position when visible, and the subject should occupy only the central safe area with generous magenta padding.
- For attack, shoot, cast, charge, and other body actions, the body height should stay close to the accepted idle/run body height. If a fixed-cell runtime is being used, reject body-action output when the body appears more than about 10-15% smaller than idle/run, even if `edge_touch_frames` is empty.
Map prop pack guardrail:
- Use square `2x2`, `3x3`, and `4x4` raw prop packs only for compact props such as rocks, shrubs, barrels, crates, lamps, small signs, pots, debris, and small ornaments.
- Do not use square prop packs for wide or collision-critical map objects: floors, platforms, ledges, terrain chunks, bridges, wall runs, ladders, roads, rails, pipes, long spike traps, gates, doors, buildings, large trees, checkpoints, exits, or build pads.
- Use one-by-one generation for unique, large, important, tall, irregular, or collision-aligned props.
- Use `1x3` or `1x4` strips for repeatable platform/floor assets, with left cap, middle repeat, right cap, and optional slope/corner/end variant.
- Use custom wide cells for multiple similar wide objects. The grid must state explicit non-square cell dimensions and must not mix compact props with platform/terrain objects.
- If a square prop pack fails due to edge touch or bad cropping, do not solve it by relaxing QC. Reclassify the object and regenerate with a more suitable sheet shape.
If a layout guide is useful, generate one before calling built-in `imagine_text_to_image`:
```bash
python3 .grok/skills/generate2dsprite/scripts/make_layout_guide.py \
--rows <rows> \
--cols <cols> \
--cell-width 384 \
--cell-height 384 \
--output <run-dir>/references/<rows>x<cols>-layout-guide.png
```
Then pass the guide PNG's sandbox path to `imagine_image_to_image` — a guide that is only "visible in conversation" never reaches the image model. Also `read_file` it so you can see the geometry. Tell `imagine_image_to_image` to use it only for invisible slot count, spacing, centering, and safe padding. The output must not reproduce guide boxes, safe-area rectangles, center marks, labels, borders, or guide background.
Use layout guides deliberately:
- recommended for `prop_pack_3x3`, `prop_pack_4x4`, tileset-like atlases, fixed multi-row animation grids, and non-directional 16-frame action sequences such as casting, summoning, charging, death, or transformation
- optional for `3x3` large idle and high-value showcase loops when previous generations drift in scale or spacing
- not the default for `4x4` four-direction walk sheets, because the guide can make directional poses too conservative; use it only after an unguided run fails layout or edge safety
### 3. Generate the raw image
Use built-in `imagine_text_to_image`.
Do not use Three.js, Canvas, SVG, HTML/CSS, PIL drawing, or other code-generated art as the raw sprite source. These are acceptable only for runtime display, debug overlays, deterministic layout guides, or postprocessing already-generated images.
After generation:
- keep the returned sandbox `file_path`
- copy that file into the working output folder as `raw-sheet.png` (or similar)
- keep the original generated image in place
- to run a further Imagine edit on a **postprocessed** PNG, pass that PNG's sandbox path to `imagine_image_to_image`
### 4. Postprocess locally
Run the processor on the raw image:
```bash
# --target is ONLY: player | npc | creature | asset
# Map character/spell/projectile/prop/summon/fx → --target asset (or player/npc/creature).
# --mode must be a known grid mode (idle/walk/attack/shoot/jump/…) OR pass both --rows and --cols.
python3 .grok/skills/generate2dsprite/scripts/generate2dsprite.py process \
--input <run-dir>/raw-sheet.png \
--target <player|npc|creature|asset> \
--mode <idle|walk|run|attack|shoot|jump|cast|hurt|projectile|impact|fx|player_sheet|…> \
--output-dir <run-dir> \
--shared-scale \
--align feet
# Custom grid example:
# --mode sheet --rows 2 --cols 3 --label-prefix frame
```
List valid targets/modes: `python3 …/generate2dsprite.py list-options`
The processor is intentionally low-level. The agent chooses:
- `rows` / `cols`
- `fit_scale`
- `align`
- `shared_scale`
- `component_mode`
- `component_padding`
- `edge_touch` rejection strategy
Use the processor to gather QC metadata, not to make aesthetic decisions for you.
For hero action bundles, process each action grid as its own sheet before any final atlas assembly. Use `component_mode=largest` for body-only hero grids. Use `component_mode=all` only for projectile, impact, aura, slash FX, or intentionally detached FX sheets, not for fixed-cell hero body attacks that need stable body scale.
### 5. QC the result
Check:
- did any frame touch the cell edge
- did any frame resize differently than intended
- did detached effects become noise
- does the sheet still read as one coherent animation
- for hero/player body actions, does the body height match the accepted idle/run scale within roughly 10-15%
- for fixed-cell runtimes, did a wide weapon trail or FX arc shrink the body inside the cell
If not, rerun with different processor settings or regenerate the raw sheet.
### 6. Return the right bundle
For a single sheet, expect:
- `raw-sheet.png`
- `raw-sheet-clean.png`
- `sheet-transparent.png`
- frame PNGs
- `animation.gif`
- `prompt-used.txt`
- `pipeline-meta.json`
For `player_sheet`, expect:
- transparent 4x4 sheet
- 16 frame PNGs
- direction strips
- 4 direction GIFs
For `spell_bundle` or `unit_bundle`, create one folder per asset in the bundle.
For `hero_action_bundle`, expect:
- one raw and processed sheet per action
- per-action frame PNGs and GIFs for visual QC
- separate projectile / muzzle / slash / impact assets when the hero shoots, casts, or uses wide melee effects
- optional assembled `engine-atlas-transparent.png` only after per-action QC passes
## Defaults
- `idle`
- small or medium actor -> `2x2`
- large creature or boss -> `3x3`
- `cast` -> prefer `2x3`
- `projectile` -> prefer `2x2` for short animated loops; use row strips only when the engine specifically requires a strip, and assemble that strip after QC when practical
- `impact` / `explode` -> prefer `2x2`
- `walk`
- topdown actor -> `4x4` for four-direction walk
- side-view asset -> `2x2`
- controllable hero or main player with multiple actions -> `hero_action_bundle`
- generate one action per raw multi-row grid sheet, not as a raw `1x4` strip
- attack/shoot/cast body sheets are body-only by default; wide slash arcs, muzzle flashes, projectiles, trails, dust, and hit impacts are separate FX/projectile/impact sheets
- default 4-frame action grid is `2x2`
- use `2x3` for 6-frame actions and `2x4`, `3x3`, `3x4`, or `4x4` for longer actions
- do not generate a mixed-action raw `4x4`, `5x5`, or custom atlas
- assemble the final atlas only as a deterministic delivery step if the game requires it
- `4x4`, `5x5`, and custom grids
- use as raw generation only for one coherent long action sequence, canonical directional locomotion, prop packs, or tileset-like atlases
- use as delivery atlases for mixed actions only after separate action sheets pass QC
- use `shared_scale` by default for any multi-frame asset where frame-to-frame consistency matters
- use `largest` component mode for hero/player body grids; use `all` for separate FX/projectile/impact sheets
## Resources
- `references/modes.md`: asset, action, bundle, and sheet selection
- `references/prompt-rules.md`: manual prompt patterns and containment rules
- `scripts/generate2dsprite.py`: postprocess primitive for cleanup, extraction, alignment, QC, and GIF export
+6
View File
@@ -0,0 +1,6 @@
# Source
Vendored from [agent-sprite-forge](https://github.com/0x0funky/agent-sprite-forge) (`53dce6055984c610d833e77887939cbd0fb1c92b`).
MIT License — see `LICENSE`.
Adapted for Grok Build / app-builder sandbox (image tool paths, `read_file` for image inspection, workspace-relative script paths).
@@ -0,0 +1,124 @@
# Modes
Use this file when the user's wording leaves room for multiple valid asset plans.
## Asset Types
- `player`: controllable overworld hero
- `npc`: role-readable town or field character
- `creature`: monster, beast, spirit, boss, summon
- `character`: side-view or non-overworld humanoid unit that is not specifically a player or NPC
- `spell`: castable magic or skill sequence
- `projectile`: loopable traveling object such as orb, arrow, fireball, bullet, beam segment
- `impact`: hit burst, explosion, contact FX
- `prop`: item, weapon, shrine object, pickup, deployable
- `summon`: conjured unit or creature entrance asset
- `fx`: generic visual effect sheet
## Actions
- `single`: one static sprite
- `idle`: looped breathing / stance / aura cycle
- `cast`: spell or skill wind-up / release
- `attack`: attack-only body animation; for controllable heroes/main characters, wide slash arcs and hit FX should be separate `fx`/`impact` sheets
- `shoot`: ranged attack body action; projectile and muzzle flash should usually be separate assets
- `jump`: airborne takeoff / rise / fall / landing action
- `hurt`: damage reaction
- `combat`: combined attack + hurt sheet
- `walk`: travel loop
- `run`: faster travel loop
- `hover`: airborne idle / travel loop
- `charge`: power-up or dash prep
- `projectile`: loopable travel motion
- `impact`: contact burst
- `explode`: stronger impact or destruction burst
- `death`: defeat / vanish / collapse sequence
## Bundle Presets
- `single_asset`: one sprite or one sheet
- `unit_bundle`
- default: `idle` + `combat`
- optional: `walk`
- `spell_bundle`
- default: `cast` + `projectile` + `impact`
- `combat_bundle`
- default: `idle` + `attack` + `hurt`
- `line_bundle`
- default: 1-3 forms
- per form, choose only the needed sheets
- `hero_action_bundle`
- default: one sheet per action, often `idle` + `run` + `attack` or `shoot` + `jump`
- keep projectiles, muzzle flashes, slash arcs, weapon trails, impacts, and dust as separate assets unless the runtime supports wider per-action cells plus explicit origins
- body action sheets should preserve idle/run body scale; split wide FX rather than letting it shrink the body in a fixed cell
- assemble an engine atlas only after each action passes QC
- `engine_atlas`
- delivery format only when it combines unrelated actions
- build from processed action sheets, not from one mixed-action raw image
## Sheet Presets
- `1x4`
- projectiles
- simple looping FX
- `2x2`
- standard idle
- attack / hurt / impact
- compact side-view walk
- `2x3`
- cast sequences
- death sequences
- slightly richer combat actions
- `3x3`
- large creature idle
- boss aura loops
- high-value showcase idles
- `4x4`
- topdown 4-direction player walk sheet
- non-directional 16-frame action sequence when the user asks for richer casting, summoning, charging, transformation, or death animation
- `5x5` or `custom_grid`
- use as raw generation only for one coherent long action sequence, prop packs, tileset-like atlases, or another single action family
- use as a delivery atlas for mixed actions only after separate action sheets have been generated and QC'd
## Agent-First Mapping Hints
- `"make a 4-direction main hero"` -> `player` + `player_sheet`
- `"make a side-view hero with idle, run, shoot, and jump"` -> `player` + `hero_action_bundle`; generate one action sheet per action and keep projectile/impact assets separate
- `"make a side-view hero melee attack"` -> `player` + `hero_action_bundle`; generate a body-only attack grid and separate slash/impact FX when the attack needs a wide arc
- `"make a healer npc"` -> `npc` + `single_asset`, `role=healer`
- `"make a healer npc walk sheet"` -> `npc` + `walk`
- `"make a boss idle"` -> `creature` + `idle`; prefer `3x3`
- `"make a wizard throwing a magic orb"` -> `spell_bundle`
- `"make a fireball projectile"` -> `projectile` + `projectile`; prefer `1x4`
- `"make a hit explosion"` -> `impact` + `impact`; prefer `2x2`
- `"make a summon entrance"` -> `summon` + `cast` or `impact`
- `"make a full fire samurai creature line"` -> `line_bundle`; plan 1-3 forms, then choose sheets per form
## Legacy Compatibility
Keep these mappings working:
- `player_sheet`: 4-direction overworld walk
- `player_walk`: 2x2 down-facing walk
- `npc_walk`: 2x2 down-facing walk
- `combat`: 2x2 attack + hurt
- `evolution`: legacy concept sheet
## Processor Defaults
- use `shared_scale=true` for any multi-frame sheet unless inconsistent scale is intentional
- use `align=bottom` or `feet` for grounded actors
- use `align=center` for floating effects, projectiles, and detached FX
- use `component_mode=largest` when raw sheets contain detached sparkles or edge debris
- use `component_mode=all` when detached effects are an intentional part of the asset silhouette
- for body-only controllable hero actions, use `component_mode=largest`; for projectile, impact, aura, slash FX, or intentionally detached FX sheets, use `component_mode=all`
- reject fixed-cell hero/player body actions when the body visibly shrinks compared with the accepted idle/run scale; split the wide FX or use a runtime with wider per-action cells and explicit origins
- use a layout guide for prop packs, tileset-like atlases, fixed atlas rows, and non-directional 16-frame VFX-heavy action sequences; avoid making it the default for 4-direction walk sheets
## Output Shape
- any sheet mode: transparent sheet + per-frame PNGs + GIF
- `player_sheet`: plus direction strips and four GIFs
- `single_asset`: cleaned transparent PNG
- `hero_action_bundle`: per-action folders, per-action GIFs, separate projectile/impact assets when needed, and optional assembled engine atlas
- bundles: one output folder per asset inside the bundle root
@@ -0,0 +1,315 @@
# Prompt Rules
Use this file when writing sprite prompts by hand.
Do not delegate prompt writing to a script unless you specifically need parity with an older generated prompt.
## Global Rules
Always keep these constraints:
- background is 100% solid flat magenta `#FF00FF`
- no gradients in the background
- no text
- no labels
- no UI
- no speech bubbles
- exact grid count only
- no borders or frames between cells
- same asset identity across frames
- same bounding box and same pixel scale across frames
- raw sprite art must come from built-in `imagine_text_to_image`, not Three.js, Canvas, SVG, HTML/CSS drawing, PIL shape drawing, procedural geometry, placeholder primitives, or code-rendered screenshots
## Style Rules
Choose the art style from the user request, project context, map context, or reference:
- `pixel_art`: general sprite default for classic 2D game actors and animation sheets.
- `clean_hd`: clean hand-painted HD 2D game asset style, crisp silhouettes, smooth surfaces, low texture noise, controlled lighting, no chunky pixels.
- `pixel_inspired`: clean modern pixel-art-inspired style without 16-bit wording, heavy dithering, or noisy microtexture.
- `retro_pixel`: 16-bit pixel art or retro JRPG pixel art, only when explicitly requested.
- `map_style` or `project-native`: match the visible reference, existing game, or `$generate2dmap` selected art style.
Do not write `16-bit`, `retro JRPG`, or `chunky pixel-art` unless the user asks for that look. For clean HD map props, explicitly say `Do not make pixel art`.
## Reference Rules
Use these rules when the user attaches a reference, points to a local image, asks for consistency with an earlier generated image, or asks for an evolution/variant of an existing sprite:
- Pass the reference to built-in `imagine_image_to_image` as a sandbox `file_path`, and `read_file` it so you can see it too. Do not assume a path string in the prompt is a visual input.
- In the prompt, say `use the provided reference image as the visual reference`.
- State what must stay fixed: silhouette family, palette, face/eyes, costume or markings, accessories, material language, and art style.
- State what may change: pose, animation phase, action energy, size progression, evolution traits, or FX intensity.
- For animation sheets, preserve the same character identity in every cell and only change the animation pose or effect state.
- For evolution lines, keep visible lineage markers while allowing larger silhouette, added details, or stronger colors per form.
- Keep the normal magenta-background and containment rules even when using a reference.
## Layout Guide Rules
Use a layout guide when the sheet needs stronger geometric control than text alone can provide:
- good fit: `3x3` and `4x4` prop packs, tileset-like atlases, fixed atlas rows, and non-directional 16-frame sequences such as casting, summoning, charging, death, or transformation
- possible fit: `3x3` large idles or showcase loops when earlier generations drift in scale, spacing, or edge safety
- risky fit: four-direction walk sheets, because guide pressure can make directional poses too centered and reduce locomotion clarity
When using a layout guide, pass the guide PNG's sandbox path to `imagine_image_to_image`. Use it only to understand the rows, columns, equal invisible frame slots, centering, spacing, and safe padding. Do not reproduce the guide: no visible boxes, no safe-area rectangles, no center marks, no labels, no borders, no guide background.
Keep the creative prompt agent-written. The layout guide only provides geometry; it must not replace the action plan, art style, identity lock, or containment rules.
## Containment Rules
For any sheet mode, say this explicitly when consistency matters:
- the entire subject must fit fully inside each cell
- no body part, effect, weapon, tail, wing tip, orb, spark, or smoke trail may cross a cell edge
- leave magenta margin on all four sides
- use the same silhouette scale in every frame
If detached FX are undesirable, say:
- no floating detached effects outside the main silhouette
If detached FX are required, say:
- detached effects must remain tightly grouped near the main subject and still fit inside the cell
## View Rules
- `topdown`: for overworld actors and player / NPC sheets
- `side`: for projectiles, side-view units, impact FX
- `3/4`: for creature battle sprites, bosses, showcase idles, side-view spellcasters
## Character Style
For `player` and `npc` when the request does not specify another style:
- top-down 2D pixel art for a 16-bit RPG overworld
- 3/4 view from slightly above
- full body visible
- chunky readable pixel-art with crisp dark outlines
- enough margin for clean engine rendering
## Map Prop Style
For `prop` assets requested by `$generate2dmap`, match the selected map art style:
- `clean_hd`: clean hand-painted HD 2D game asset style, crisp silhouettes, smooth painted surfaces, low texture noise, controlled accent lighting, no chunky pixels.
- `pixel_inspired`: clean modern pixel-art-inspired prop, crisp readable shape, no 16-bit wording, no heavy dithering.
- `retro_pixel`: 16-bit or retro JRPG pixel-art prop, only when the map is explicitly retro pixel.
For clean HD props, use mostly front-facing top-down RPG object view: upright objects are vertical and centered, with only a small visible top face. Avoid strong isometric diagonal rotation unless requested.
## Creature and FX Style
For `creature`, `spell`, `projectile`, `impact`, `summon`, and `fx`:
- strong silhouette
- readable body colors or effect shape
- battle-ready or gameplay-readable pose
- avoid painterly composition drift between frames
- if humanoid, keep it clearly non-player unless the user explicitly wants a player-like unit
## Action Rules
### `idle`
Use:
- neutral stance
- subtle motion
- weight shift or aura pulse
- strongest idle accent before looping
Prefer:
- `2x2` for standard actors
- `3x3` for large creatures and showcase idles
### `cast`
A `2x3` cast is often the best default:
- readiness
- energy gather
- stronger gather
- release start
- release peak
- settle or hold
### `attack`
For a compact attack-only sheet, describe:
- wind-up
- strike
- follow-through
- recovery
For controllable heroes, main characters, and fixed-cell game sprites, write attack body prompts as body-only:
- no detached slash arc
- no wide weapon trail
- no muzzle flash
- no projectile
- no impact burst
- no detached dust cloud
- weapon remains close enough that the body bbox stays near idle/run size
- body height and feet/bottom anchor match the accepted idle/run sheet
If the attack needs a large slash arc, sword trail, muzzle flash, or hit spark, generate it as a separate `fx`, `projectile`, or `impact` sheet and layer it in the runtime.
### `hurt`
For a hurt-only sheet, describe:
- impact
- recoil
- stagger
- recovery
### `combat`
For a compact combined sheet:
- top-left: attack wind-up
- top-right: attack strike
- bottom-left: hurt impact
- bottom-right: hurt recovery
### `projectile`
Usually prefer `1x4` or `2x2`.
Describe:
- same projectile identity in all frames
- travel direction stays consistent
- shape changes are small and loopable
- glow or trail stays inside the frame
### `impact` / `explode`
Usually prefer `2x2`.
Describe:
- ignition or contact
- expansion
- peak burst
- fade or collapse
### `walk` / `run` / `hover`
State the travel behavior clearly:
- grounded stride
- hover bob
- crawl
- slither
- mechanical glide
## Sheet-Specific Rules
### Mixed-action atlas guardrail
Do not use a single raw generated sheet to pack unrelated actions just because the target engine wants a `4x4`, `5x5`, or custom atlas.
Avoid prompts like:
- row 1 idle, row 2 run, row 3 shoot, row 4 jump
- first row walk, second row attack, third row hurt, fourth row death
- one big atlas containing every hero action
For controllable heroes, main characters, and high-value player assets:
1. Generate each action as its own multi-row grid sheet, usually `2x2` for 4-frame actions, `2x3` for 6-frame actions, and `2x4`, `3x3`, `3x4`, or `4x4` for longer actions.
2. Keep attack/shoot/cast body animation separate from projectile, muzzle flash, slash arc, weapon trail, impact, and dust unless the runtime explicitly supports wider per-action cells plus explicit origins.
3. Process and visually QC each action independently for feet line, body center, scale, silhouette, and edge safety.
4. Reject a body action when the body appears more than about 10-15% smaller than idle/run because a wide FX bbox forced it to shrink.
5. Assemble a `4x4`, `5x5`, or custom engine atlas only after the separate action sheets pass QC.
Allowed raw multi-row sheets:
- canonical four-direction locomotion sheets where every row is the same walk/run action in a different direction
- one continuous non-directional long action sequence, read left-to-right across rows
- prop packs or tileset-like atlases where each cell is intentionally a separate object
- compact low-stakes enemy combat sheets, but not controllable hero production assets
### `4x4` player sheet
Use:
- row 1: down
- row 2: left
- row 3: right
- row 4: up
- column 1: neutral
- column 2: left foot forward
- column 3: neutral again
- column 4: right foot forward
Do not use a layout guide by default for this sheet. Try an unguided prompt first unless the previous result crossed cell edges or failed the grid shape.
### `3x3` large idle
Say:
- exactly 9 equal cells in a `3x3` grid
- same bounding box in all 9 cells
- subject fills only about 55% to 65% of each cell
- no edge crossing anywhere
Use a layout guide when a previous 3x3 result has uneven spacing, inconsistent scale, or edge-touching frames.
### `4x4` non-directional action sequence
Use for casting, summoning, charging, transformation, death, and other single-action loops:
- exactly 16 equal cells in a `4x4` grid
- read frames left-to-right across each row, then continue on the next row
- describe each phase in order, from anticipation through peak action to settle or loop return
- keep the subject identity stable while allowing pose, energy, and compact attached effects to change
- use a layout guide when the action includes VFX, portals, circles, summons, or other elements that might cross cell boundaries
Do not use this format as a shortcut for four unrelated hero actions. If the requested rows are different actions, treat it as a `hero_action_bundle` or `engine_atlas` delivery problem instead.
### `5x5` and custom grids
Use raw `5x5` or custom-grid generation only when the entire sheet is one coherent action family, a prop pack, a tileset-like atlas, or a single long sequence.
For mixed action requirements, generate each action separately and assemble the final grid after QC. The assembled atlas is a delivery artifact, not the raw image-generation target.
### `1x4` projectile
Say:
- exactly 4 equal cells in one row
- same projectile size in every frame
- only the internal energy or shape pulse changes
## Bundle Prompting
When generating a bundle, write each asset prompt independently.
Good default decomposition:
- caster unit
- projectile
- impact
or:
- idle
- combat
- walk
Do not try to force unrelated assets into one giant sheet.
## Quick Prompt Pattern
1. state the asset type and sheet shape
2. describe the subject identity
3. if applicable, state the reference role and invariants
4. describe frame-by-frame motion
5. restate same-scale and containment rules
6. restate magenta background and no-text rules
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Create a layout-only guide image for sprite sheet generation."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw
def draw_dashed_line(
draw: ImageDraw.ImageDraw,
start: tuple[int, int],
end: tuple[int, int],
*,
fill: str,
width: int,
dash: int,
gap: int,
) -> None:
x1, y1 = start
x2, y2 = end
if x1 == x2:
for y in range(min(y1, y2), max(y1, y2), dash + gap):
draw.line((x1, y, x2, min(y + dash, max(y1, y2))), fill=fill, width=width)
return
if y1 == y2:
for x in range(min(x1, x2), max(x1, x2), dash + gap):
draw.line((x, y1, min(x + dash, max(x1, x2)), y2), fill=fill, width=width)
return
raise ValueError("draw_dashed_line only supports horizontal or vertical lines")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rows", type=int, required=True)
parser.add_argument("--cols", type=int, required=True)
parser.add_argument("--cell-width", type=int, default=384)
parser.add_argument("--cell-height", type=int, default=384)
parser.add_argument("--safe-margin-x", type=int, default=52)
parser.add_argument("--safe-margin-y", type=int, default=52)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--label-cells",
action="store_true",
help="Draw small row,column labels. Leave off for normal imagegen references.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.rows <= 0 or args.cols <= 0:
raise SystemExit("--rows and --cols must be positive")
if args.cell_width <= 0 or args.cell_height <= 0:
raise SystemExit("--cell-width and --cell-height must be positive")
width = args.cols * args.cell_width
height = args.rows * args.cell_height
image = Image.new("RGB", (width, height), "#f8f8f8")
draw = ImageDraw.Draw(image)
for row in range(args.rows):
for col in range(args.cols):
left = col * args.cell_width
top = row * args.cell_height
right = left + args.cell_width - 1
bottom = top + args.cell_height - 1
safe_left = left + args.safe_margin_x
safe_top = top + args.safe_margin_y
safe_right = right - args.safe_margin_x
safe_bottom = bottom - args.safe_margin_y
draw.rectangle((left, top, right, bottom), outline="#111111", width=4)
draw.rectangle(
(safe_left, safe_top, safe_right, safe_bottom), outline="#2f80ed", width=3
)
center_x = left + args.cell_width // 2
center_y = top + args.cell_height // 2
draw_dashed_line(
draw,
(center_x, safe_top),
(center_x, safe_bottom),
fill="#b8b8b8",
width=2,
dash=14,
gap=16,
)
draw_dashed_line(
draw,
(safe_left, center_y),
(safe_right, center_y),
fill="#b8b8b8",
width=2,
dash=14,
gap=16,
)
if args.label_cells:
draw.text((left + 12, top + 10), f"{row + 1},{col + 1}", fill="#777777")
args.output.parent.mkdir(parents=True, exist_ok=True)
image.save(args.output)
if __name__ == "__main__":
main()
+201
View File
@@ -0,0 +1,201 @@
---
name: imagine
description: >
How to use the Imagine tools in Grok Build: imagine_text_to_image,
imagine_image_to_image, imagine_reference_to_image, imagine_text_to_video,
imagine_image_to_video, imagine_reference_to_video, and render_file for chat
previews. When to build a visual with code instead of generating it,
prompt-craft, reference-first handling of real people, factual grounding, and
asset-consistency. Load this whenever generating or editing an image or video
is on the table. Tool-usage-driven, not triggered by a user merely mentioning
images.
metadata:
short-description: "Prompting and workflow guidance for Imagine image/video tools"
user-invocable: false
---
# Imagine
Grok Build uses the **split Imagine computer stack** (`grok_computer` variants).
There is **no** consolidated `imagine_image` or `imagine_video` tool — always
call the modality-specific name from the table below.
| Tool | Role |
|------|------|
| `imagine_text_to_image` | New image from a text prompt only (no source). |
| `imagine_image_to_image` | Edit / restyle **one** existing image (sandbox path). |
| `imagine_reference_to_image` | Combine **2+** reference images (sandbox paths). |
| `imagine_text_to_video` | New video from a text prompt only (no source). |
| `imagine_image_to_video` | Animate **one** still (sandbox path) into a video. |
| `imagine_reference_to_video` | Video from **1+** reference images (sandbox paths). |
| `render_file` | Show a sandbox image/video path to the user in chat. |
**Path-based handles.** These tools read/write the shared sandbox:
- Generation returns a sandbox **`file_path`** (under artifacts). Open it with
`read_file` / shell; show the user with **`render_file`**.
- Edit / animate tools take that path (or paths) as input — match the live
schema (`image`, `images`, etc.). Never invent paths.
- Never call `imagine_image` / `imagine_video`. Asset-id helpers
(`imagine_create_asset` / `imagine_view_media` / `render_imagine_media`) are
**not** on this stack.
Apply this whenever you're considering or about to call any of these tools.
## Handle flow (mandatory mental model)
```text
generate → file_path → render_file (show user) / read_file or scripts (QC)
edit / animate → pass prior file_path(s) into image_to_* / reference_to_*
```
- **Use the path the tool actually returned** — do not invent filesystem paths.
- **Show the user media** with `render_file`, not ad-hoc markdown image links.
## Build accurate visuals with code, not the image tools
1. **Image models are unreliable at exact text, numbers, and structure.** They can handle short text or a simple layout, but they often garble words, invent numbers, draw chart bars that match no data, or point diagram arrows nowhere, and the more that has to be exact, the worse they do. A detailed prompt doesn't make it dependable, and another `imagine_image_to_image` edit usually won't fix it. So when a result needs specific text, data, or structure to be correct (charts from real numbers, labeled or technical diagrams, math explainers, tables, screens with real copy), construct the asset with code, where you control the exact content. Prefer HTML and CSS, which give much better layout, typography, and polish than Python plotting. When only the look matters (photos, illustrations, characters, scenes, decorative art), the image tools are the right choice. Which one fits depends on what the output needs to get right, not on how the request is worded.
## Verifying discrete accuracy (loop)
When the output must get specific text, numbers, data, or structure right, don't trust the first result - verify it in a loop:
1. Produce the result (generate, or per *Build accurate visuals with code*, construct it in code).
2. Inspect the actual output - use `read_file` (image understanding) on the result path - and confirm every word, number, label, and structural detail matches the requirement, and that nothing overlaps, clips, or runs off-canvas.
3. If anything is wrong, fix and re-verify:
- Garbled text, invented numbers, or broken layout from an image model? Don't just re-prompt - it will likely garble it again. Rebuild it with code.
- Overlapping or clipped elements in code-built output? Re-lay-out with auto-layout (HTML/CSS) rather than nudging coordinates by hand.
- Otherwise make one targeted edit via `imagine_image_to_image` with the prior path.
4. Only finish when the discrete content is exactly correct. If it can't be made accurate, tell the user instead of shipping something wrong.
## Core Principles
1. **You own the prompt.** If the user gives a detailed prompt or asks you to use theirs, use it verbatim. Otherwise craft the final prompt: front-load the subject, give strong high-level direction for mood, composition, lighting, and style without over-specifying every detail, write natural prose rather than keyword tags, and describe positively instead of using negative prompts. For edits, describe only what changes. Target 2-5 sentences.
2. **Reference-first for real people.** Never use pure text-to-image for a named real person or group, including face swaps, posters, cartoons, and cinematic or editorial depictions. Use `imagine_image_to_image` **with a real reference path** instead, and never produce non-consensual, sexualized, or minor-involving likenesses. See Real People and References for the procedure.
3. **Ground facts with search first.** If any part of the request depends on a real-world fact, identity, brand or product, place, event, or top/latest/current result, search the web before generating and put the actual verified details into the prompt. Don't rely on memory, and don't write vague placeholders like "the current president"; write the verified name.
4. **Reuse a base for consistency.** When the same character, object, or setting must appear across multiple images, generate one base with `imagine_text_to_image`, keep its `file_path`, then pass that path to `imagine_image_to_image` for every variation. Don't re-run text-to-image from scratch for a recurring subject.
5. **Handle failures gracefully.** On a moderation or safety block, stop; don't retry and don't paraphrase the prompt to evade the filter. Tell the user it was blocked and offer a different direction. If a reference is weak or a result looks off-target, say so and ask for an upload or redirect rather than silently iterating.
6. **Plan multi-step workflows.** Sequence the steps; only parallelize generations that belong to the same step.
7. **Review at the end.** Confirm the generations you intended actually executed and match what was asked. Render final assets with `render_file`.
8. **Don't assume tool behavior.** Don't invent tool parameters, return values, or environment capabilities that aren't actually provided; verify rather than guess.
## Choosing the tool
| Situation | Call |
|-----------|------|
| New image, no source | `imagine_text_to_image` with `prompt` (+ `aspect_ratio`) |
| Edit / restyle / recolor one existing image | `imagine_image_to_image` with `prompt` + source path |
| Combine 2+ reference images into one | `imagine_reference_to_image` with `prompt` + source paths |
| Iterate on a previous result | `imagine_image_to_image` with prior path |
| Named real person or group | `imagine_image_to_image` with a real reference path after web search |
| Generic / invented subject from scratch | `imagine_text_to_image` |
| New video, no source | `imagine_text_to_video` with `prompt` |
| Animate one still | `imagine_image_to_video` with that still's path |
| Multi-ref video | `imagine_reference_to_video` with image path(s) |
Rule of thumb: **no refs → text_to_*; one ref → image_to_*; 2+ refs → reference_to_*.**
## `imagine_text_to_image`
Generate a new image from a text prompt.
Inputs:
- `prompt` (required) - full description of the desired image.
- `aspect_ratio` - one of `1:1`, `3:4`, `4:3`, `2:3`, `3:2`, `9:16`, `16:9`, `21:9`, `5:2`, `50:11`, or `unknown`. Use `16:9` for OG share cards and `50:11` for the X feed banner when generating a custom `public/x-banner.jpg` for **games** (every app wires `x:game:image`; only games call Imagine for it — see the `og` skill); for a true 2:1 canvas, call the xAI Images API.
To produce multiple variations, make multiple `imagine_text_to_image` calls with distinct prompts. The tool does not expose `n` or `count` parameters.
## `imagine_image_to_image`
Edit one existing image.
Inputs (names follow the live schema — typically a singular sandbox path):
- `prompt` (required) - what to change (describe only the edit).
- Source path field (required) - sandbox path returned by a prior generation or download.
- `aspect_ratio` - only set when the user explicitly wants a ratio change.
## `imagine_reference_to_image`
Compose one image from 2+ reference paths.
Inputs:
- `prompt` (required).
- Source paths (required) - 2+ sandbox paths.
- `aspect_ratio` - optional.
For a single source edit, use `imagine_image_to_image` instead.
## `imagine_text_to_video`
Generate a new video from a text prompt only (no source frame).
Inputs:
- `prompt` (required) - short present-tense shot description.
- `duration` / `aspect_ratio` / resolution fields as exposed by the live schema.
Prefer short shots; same prompt-craft rules as image-to-video below.
## `imagine_image_to_video`
Animate one still into a video.
Inputs:
- Singular source still path (required).
- `prompt` - short present-tense shot (recommended; required by some variants).
- `duration` - `6` (default), `10`, or `15` when exposed.
- `aspect_ratio` - optional; omit to keep the source ratio when animating one image.
**Prefer short shots.** Build video as a planned sequence of short clips, not one long take:
1. Plan the story as shots - one beat each.
2. Prefer more 6s shots over fewer long ones.
3. Create each shot's source still with `imagine_text_to_image` / `imagine_image_to_image` (keep character paths consistent).
4. Animate with `imagine_image_to_video` + that still path.
Key behaviors:
- **Prompt-craft:** one short, vivid moment in present tense with a clear camera movement, in 1-2 sentences.
- **Minimal but interesting:** one clear subject and a single simple motion or camera move.
- **Complex source?** Keep the subject fixed and move only the camera, or break into simpler shots.
- **Real people:** reference-first - drive from a verified reference; never animate a named person without one.
- Don't loop the same clip unless asked.
- Assemble multi-shot timelines with FFmpeg stream copy on the returned video paths.
## `imagine_reference_to_video`
Video from one or more reference image paths guided by a prompt. Prefer composing a single still with `imagine_reference_to_image` first, then `imagine_image_to_video`, when the goal is a clean first frame.
## Writing Strong Prompts
Describe, roughly in this order: **subject -> action/pose -> setting -> style -> composition -> lighting/mood -> key details.**
- Be specific and concrete; lead with the most important elements.
- State what to include rather than what to exclude.
- Use one coherent scene per prompt.
- Match `aspect_ratio` to the use case: `9:16` for phone/story, `16:9` for banner/video frame or OG share cards, `1:1` for avatar/icon.
## Real People and References
1. Search the web first to confirm identity, role, relationship, or event, even when it seems obvious.
2. Obtain a strong reference image on disk (user upload or search → sandbox path), then call `imagine_image_to_image` with that path. A user-uploaded photo is best.
3. If no suitable reference exists, ask the user to upload one rather than generating from a weak base.
## Showing results
- Call `render_file` with the sandbox `file_path` so the user sees the image/video in chat.
- For your own QC and scripts, `read_file` / shell on that path.
Game sprites and maps have their own pipelines — follow `generate2dsprite`,
`video2dsprite`, and `generate2dmap` for those.
## Failure modes to avoid
- Calling consolidated names `imagine_image` or `imagine_video` (they are not available).
- Passing a list to `imagine_image_to_image` / `imagine_image_to_video` (singular source only).
- Inventing a filesystem path that the tool never returned.
- Running chroma/ffmpeg on a path you made up without a real generation/`file_path`.
+111
View File
@@ -0,0 +1,111 @@
---
name: multiplayer-p2p
description: >
Peer-to-peer realtime multiplayer over WebRTC data channels: every user of
the deployed app connects directly to every other user (full mesh), the
server only brokers the handshake at /api/rtc. Lowest possible latency, zero
per-message server cost. Use for 2-8 player co-op/casual realtime: shared
cursors, drawing, party games, casual action. Triggers: p2p, peer to peer,
webrtc, low latency multiplayer, direct connection.
metadata:
short-description: "WebRTC P2P mesh, signaled at /api/rtc"
user-invocable: false
---
# Multiplayer (WebRTC peer-to-peer)
All visitors on the same deployed domain join one default room, opening a
native WebRTC data channel directly to every other visitor — game traffic
itself never touches a server. A tiny relay at `/api/rtc` handles only the
routing of the connection handshake (SDP/ICE) while peers connect. What you
use from the kit is client-side only; the relay is yours.
Latency is browser↔browser (often 540ms) with zero per-tick server cost.
| Piece | Path |
|---|---|
| Mesh primitive (start here) | `P2PRoom` from `@/lib/multiplayer` |
| React room binding (optional, you create) | `src/lib/multiplayer/use-p2p-room.ts` |
| Signaling relay (you create) | `src/lib/multiplayer/signaling.server.ts` |
| HTTP mount (you create) | `src/routes/api/rtc.ts` |
**Trust model — read before choosing P2P.** There is no server authority:
every peer runs its own copy of the rules and can lie (position, score,
anything). Peers also learn each other's IP addresses during ICE. P2P is for
**co-op and casual play among people who choose to play together** — never for
competitive ranking, cheat-sensitive, or anonymous-stranger matchmaking.
Competitive or cheat-sensitive play is not supported in this template: push
back in product terms rather than shipping it on P2P.
Practical limits: a full mesh is O(N²) connections — cap rooms at ~8 peers.
Roughly 1020% of peer pairs sit behind strict NATs and cannot connect; the
kit surfaces this per peer as `connectionState: "failed"` — show it in the
UI rather than hanging.
## Setup (once)
Create the two server files — nothing works without them:
1. `src/lib/multiplayer/signaling.server.ts` — the DB-backed signaling relay
(Neon deployed, PGLite in preview).
2. `src/routes/api/rtc.ts` — mounts it at `/api/rtc` (GET poll, POST
signal/leave).
**Copy both from `references/signaling-relay.md`**, which also carries the
schema note: the relay creates its own two tables on first use
(`CREATE TABLE IF NOT EXISTS`), so **nothing goes in `migrations/`** unless you
deliberately want to own the schema.
## Using the primitive
`P2PRoom` is framework-free, and a "room" is just a rendezvous key — a lobby
code, a 1:1 call id, a shared-document id, any string (≤64 chars). Any
architecture sits on top of the same three calls:
```ts
import { P2PRoom } from "@/lib/multiplayer";
const p2p = new P2PRoom({
room: "doc-42",
selfId: myId,
name: "ani",
onPeersChanged: (peers) => render(peers),
onMessage: (from, data, channel) => apply(from, data, channel),
});
await p2p.join();
p2p.broadcast(state); // unreliable "state" channel — game-rate, stale drops
p2p.send(event, to); // reliable channel — exactly-once events (to optional)
p2p.close();
```
For the common "everyone on this app plays together" shape in React, copy the
`useP2PRoom` hook (plus a worked component: game-rate broadcast loop at ~20
sends/s, reliable one-shot events) from `references/react-binding.md`.
Patterns:
1. `broadcast()` = unreliable/unordered, for continuously-refreshed state
(positions, cursors). `send()` = reliable/ordered, for events that must
arrive exactly once. Never stream game-rate state on `send()`; interpolate
between broadcasts for smooth motion.
2. Late joiners know nothing: on a new peer appearing in `p2p.peers`, an
existing peer should `send()` it the current shared state. Exactly one
peer must answer: compare ids among the peers that were ALREADY in the
room (your `selfId` plus `p2p.peers` minus the newcomer) and answer only
if your `selfId` is the smallest — so two simultaneous joiners neither
double-answer nor go unanswered.
3. Room ids: omit for "everyone on this app plays together"; pass
`room: code` for private lobbies (generate a short code, put it in the URL).
4. Peers disappear without goodbye (tab close, sleep): treat a peer missing
from `p2p.peers` as gone and drop its entities.
5. A React binding that captures `room`/`name` on first render (the one in
`references/react-binding.md` does) needs a remount to change them — key the
component on the room code.
## Diagnostics
Each entry in `p2p.peers` carries `connectionState`, `rttMs` (data-channel
ping), and `candidateType` (`host`/`srflx` = direct). To override STUN, add
`VITE_STUN_URLS` (comma-separated) to `.grok/app-env.json` and **restart the dev
server** (Vite reads env at startup; HMR will not pick it up) — never write a
`.env` in this sandbox.
@@ -0,0 +1,144 @@
# React room binding (optional — copy if it fits your app)
For the common "everyone on this app plays together" shape, copy this hook to
`src/lib/multiplayer/use-p2p-room.ts` and adapt it freely — it is yours, not
part of the kit. It captures `room`/`name` on first render, so changing them
later requires remounting the component (key it on the room code).
```ts
/**
* React binding for P2PRoom. Identity and room id are captured once on mount
* (useState initializers) so re-renders never tear down the mesh: the P2PRoom
* instance lives exactly as long as the component that mounted it, and
* changing `room`/`name` requires a remount (key the component on them).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { P2PRoom, type PeerInfo } from "./p2p";
export interface UseP2PRoomOptions {
/** Defaults to a per-deployment room derived from the hostname. */
room?: string;
name?: string;
}
export interface P2PRoomHandle {
selfId: string;
room: string;
/** Remote peers only (self excluded), with live connection diagnostics. */
peers: PeerInfo[];
joined: boolean;
/** Unreliable game-state fanout to every connected peer. */
broadcast: (data: unknown) => void;
/** Reliable ordered send to one peer (or all when peerId is omitted). */
send: (data: unknown, peerId?: string) => void;
/** Subscribe to incoming messages; returns an unsubscribe function. */
onMessage: (
fn: (from: string, data: unknown, channel: "state" | "reliable") => void,
) => () => void;
}
function defaultRoom(): string {
if (typeof window === "undefined") return "room-ssr";
// DNS labels can be 63 chars; the signaling ID regex caps room ids at 64 —
// truncate so `room-` + label always fits.
return `room-${window.location.hostname.split(".")[0]}`.slice(0, 64);
}
export function useP2PRoom(options: UseP2PRoomOptions = {}): P2PRoomHandle {
const [selfId] = useState(() => `p-${Math.random().toString(36).slice(2, 10)}`);
const [room] = useState(() => options.room ?? defaultRoom());
const [name] = useState(() => options.name ?? selfId);
const [peers, setPeers] = useState<PeerInfo[]>([]);
const [joined, setJoined] = useState(false);
const roomRef = useRef<P2PRoom | null>(null);
const listeners = useRef(
new Set<(from: string, data: unknown, channel: "state" | "reliable") => void>(),
);
useEffect(() => {
const p2p = new P2PRoom({
room,
selfId,
name,
onPeersChanged: setPeers,
onMessage: (from, data, channel) => {
for (const fn of listeners.current) fn(from, data, channel);
},
// `joined` flips on the FIRST successful poll — join() itself resolves
// even when the first poll fails (the loop keeps retrying).
onConnected: () => setJoined(true),
});
roomRef.current = p2p;
void p2p.join();
return () => {
roomRef.current = null;
p2p.close();
};
}, [room, selfId, name]);
// Stable identities (both close over refs) so consumers can safely list
// these in effect deps without re-subscribing every render.
const broadcast = useCallback((data: unknown) => roomRef.current?.broadcast(data), []);
const send = useCallback(
(data: unknown, peerId?: string) => roomRef.current?.send(data, peerId),
[],
);
const onMessage = useCallback(
(fn: (from: string, data: unknown, channel: "state" | "reliable") => void) => {
listeners.current.add(fn);
return () => {
listeners.current.delete(fn);
};
},
[],
);
return { selfId, room, peers, joined, broadcast, send, onMessage };
}
```
Used in a component:
```tsx
import { useP2PRoom } from "@/lib/multiplayer";
function Game() {
const p2p = useP2PRoom({ name: "ani" }); // room defaults per deployment
const [positions, setPositions] = useState<Record<string, Pos>>({});
useEffect(
() =>
p2p.onMessage((from, data, channel) => {
if (channel === "state") {
setPositions((p) => ({ ...p, [from]: data as Pos }));
}
}),
[p2p.onMessage],
);
// Game-rate state: broadcast on the unreliable channel (stale packets drop).
// 20-30 sends/s is plenty; interpolate between updates for smooth motion.
useEffect(() => {
let raf = 0;
let lastSent = 0;
const loop = (now: number) => {
if (now - lastSent >= 50) {
// ~20 sends/s
p2p.broadcast(myPositionRef.current);
lastSent = now;
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [p2p.broadcast]);
// One-shot actions (chat, "start game"): reliable + ordered.
const sendChat = (text: string) => p2p.send({ chat: text });
return <Board peers={p2p.peers} positions={positions} />;
}
```
@@ -0,0 +1,269 @@
# Signaling relay and API route (create once)
The relay is **yours**, not part of the kit: copy it as-is or serve the same
`RtcPollResponse` shape from any store. Only rendezvous traffic (roster +
SDP/ICE) passes through it; game data flows peer-to-peer.
## Schema — nothing to do by default
The relay below creates its two tables on first use
(`CREATE TABLE IF NOT EXISTS`, once per process) — nothing ships in
`migrations/` and the template itself never touches your database. If you'd
rather own or extend the schema (extra columns, your own migration ordering),
copy this into one of your app migrations; `IF NOT EXISTS` makes the runtime
ensure and your migration coexist safely:
```sql
-- CREATE TABLE IF NOT EXISTS webrtc_peers (
-- room TEXT NOT NULL,
-- peer_id TEXT NOT NULL,
-- name TEXT NOT NULL DEFAULT '',
-- last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
-- PRIMARY KEY (room, peer_id)
-- );
-- CREATE TABLE IF NOT EXISTS webrtc_signals (
-- id BIGSERIAL PRIMARY KEY,
-- room TEXT NOT NULL,
-- to_peer TEXT NOT NULL,
-- from_peer TEXT NOT NULL,
-- kind TEXT NOT NULL,
-- payload JSONB NOT NULL,
-- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- );
-- CREATE INDEX IF NOT EXISTS webrtc_signals_inbox
-- ON webrtc_signals (room, to_peer, id);
```
## `src/lib/multiplayer/signaling.server.ts`
Copy this file as-is (or adapt it — it is yours, not part of the kit):
```ts
// src/lib/multiplayer/signaling.server.ts
/**
* WebRTC signaling over the app database (Neon deployed, PGLite in preview).
* Only rendezvous traffic passes through here — roster + SDP/ICE relay while a
* mesh forms; game data then flows peer-to-peer. DB-backed so any serverless
* instance can serve any poll. Mount at /api/rtc (see the multiplayer-p2p
* skill); the client side lives in `@/lib/multiplayer`.
*
* The GET poll is the whole peer lifecycle: the first poll (since=0) IS the
* join — it registers the peer, returns the roster, and prunes stale rows.
* Peer ids are random per mount, so a fresh inbox never has old signals to
* skip and no join/cursor handshake is needed.
*/
import { z } from "zod";
import { getSql, type Sql } from "@/lib/db";
import type { PeerRow, RtcPollResponse, SignalRow } from "./p2p";
const ID = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const signalSchema = z.object({
op: z.literal("signal"),
room: ID,
from: ID,
to: ID,
kind: z.enum(["offer", "answer", "ice"]),
// SDP offers are typically 310KB; the cap only blocks abuse (payload is
// re-serialized at insert — cheap at this size). An absent
// payload is rejected here (JSON.stringify(undefined) has no .length).
payload: z.unknown().refine((v) => v !== undefined && JSON.stringify(v).length <= 32_768, {
message: "payload too large",
}),
});
const leaveSchema = z.object({ op: z.literal("leave"), room: ID, peer: ID });
const postSchema = z.discriminatedUnion("op", [signalSchema, leaveSchema]);
const PEER_TTL_SECONDS = 30;
const SIGNAL_TTL_SECONDS = 60;
/**
* The kit ships no migration: tables are created on first use (IF NOT EXISTS)
* so the app's migrations/ namespace stays fully in the agent's hands. Agents
* who want to own/extend the schema can copy the DDL from the multiplayer-p2p
* skill into their own migration — both coexist safely. Memoized on globalThis
* (the db.ts pattern) so dev HMR never runs two ensures concurrently; a failed
* ensure clears the slot so the next request retries.
*/
const globalRef = globalThis as typeof globalThis & {
__rtcSchemaPromise__?: Promise<void>;
};
function ensureSchema(sql: Sql): Promise<void> {
globalRef.__rtcSchemaPromise__ ??= (async () => {
await sql.query(
`CREATE TABLE IF NOT EXISTS webrtc_peers (
room TEXT NOT NULL,
peer_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (room, peer_id)
)`,
);
await sql.query(
`CREATE TABLE IF NOT EXISTS webrtc_signals (
id BIGSERIAL PRIMARY KEY,
room TEXT NOT NULL,
to_peer TEXT NOT NULL,
from_peer TEXT NOT NULL,
kind TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
);
await sql.query(
`CREATE INDEX IF NOT EXISTS webrtc_signals_inbox
ON webrtc_signals (room, to_peer, id)`,
);
})().catch((err) => {
globalRef.__rtcSchemaPromise__ = undefined;
throw err;
});
return globalRef.__rtcSchemaPromise__;
}
async function roster(sql: Sql, room: string): Promise<PeerRow[]> {
// LIMIT bounds the blast radius of room-stuffing; the mesh caps out ~8.
const rows = await sql.query<{ peer_id: string; name: string }>(
`SELECT peer_id, name FROM webrtc_peers
WHERE room = $1 AND last_seen > now() - make_interval(secs => $2)
ORDER BY peer_id LIMIT 32`,
[room, PEER_TTL_SECONDS],
);
return rows.map((r) => ({ id: r.peer_id, name: r.name }));
}
async function touchPeer(sql: Sql, room: string, peer: string, name: string) {
await sql.query(
`INSERT INTO webrtc_peers (room, peer_id, name, last_seen)
VALUES ($1, $2, $3, now())
ON CONFLICT (room, peer_id)
DO UPDATE SET last_seen = now(), name = EXCLUDED.name`,
[room, peer, name],
);
}
/**
* Rows are ephemeral; GC rides the polls instead of a cron: joins (since=0)
* always prune, and ~2% of all other polls do too — so a busy room whose
* cursors always advance still gets swept, without every heartbeat paying
* the two DELETEs.
*/
async function prune(sql: Sql) {
await Promise.all([
sql.query(`DELETE FROM webrtc_signals WHERE created_at < now() - make_interval(secs => $1)`, [
SIGNAL_TTL_SECONDS,
]),
sql.query(`DELETE FROM webrtc_peers WHERE last_seen < now() - make_interval(secs => $1)`, [
PEER_TTL_SECONDS,
]),
]);
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
/** GET /api/rtc?room&peer&name&since — join (since=0), heartbeat, and inbox. */
async function handleGet(url: URL): Promise<Response> {
const parsed = z
.object({
room: ID,
peer: ID,
name: z.string().max(64).default(""),
since: z.coerce.number().int().min(0).default(0),
})
.safeParse({
room: url.searchParams.get("room"),
peer: url.searchParams.get("peer"),
name: url.searchParams.get("name") ?? "",
since: url.searchParams.get("since") ?? 0,
});
if (!parsed.success) return json({ error: "invalid query" }, 400);
const { room, peer, name, since } = parsed.data;
const sql = await getSql();
await ensureSchema(sql);
if (since === 0 || Math.random() < 0.02) await prune(sql);
await touchPeer(sql, room, peer, name);
const rows = await sql.query<{
id: number;
from_peer: string;
kind: SignalRow["kind"];
payload: unknown;
}>(
`SELECT id, from_peer, kind, payload FROM webrtc_signals
WHERE room = $1 AND to_peer = $2 AND id > $3
ORDER BY id LIMIT 200`,
[room, peer, since],
);
const body: RtcPollResponse = {
peers: await roster(sql, room),
signals: rows.map((r) => ({
id: r.id,
from: r.from_peer,
kind: r.kind,
payload: r.payload,
})),
};
return json(body);
}
async function handlePost(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return json({ error: "invalid JSON" }, 400);
}
const parsed = postSchema.safeParse(body);
if (!parsed.success) return json({ error: "invalid request" }, 400);
const msg = parsed.data;
const sql = await getSql();
await ensureSchema(sql);
if (msg.op === "signal") {
await sql.query(
`INSERT INTO webrtc_signals (room, to_peer, from_peer, kind, payload)
VALUES ($1, $2, $3, $4, $5)`,
[msg.room, msg.to, msg.from, msg.kind, JSON.stringify(msg.payload)],
);
} else {
await sql.query(`DELETE FROM webrtc_peers WHERE room = $1 AND peer_id = $2`, [
msg.room,
msg.peer,
]);
}
return json({ ok: true });
}
/** Request entrypoint for the /api/rtc route (GET poll, POST signal/leave). */
export async function handleSignaling(request: Request): Promise<Response> {
try {
if (request.method === "GET") return await handleGet(new URL(request.url));
if (request.method === "POST") return await handlePost(request);
return json({ error: "method not allowed" }, 405);
} catch (error) {
console.error("[rtc] signaling error:", error);
return json({ error: "signaling failed" }, 500);
}
}
```
## Mount the API route
```ts
// src/routes/api/rtc.ts
import { createFileRoute } from "@tanstack/react-router";
import { handleSignaling } from "@/lib/multiplayer/signaling.server";
const handle = ({ request }: { request: Request }) => handleSignaling(request);
export const Route = createFileRoute("/api/rtc")({
server: { handlers: { GET: handle, POST: handle } },
});
```
+157
View File
@@ -0,0 +1,157 @@
---
name: neon
description: >
Use Neon Postgres (the database) in this TanStack Start app. Use when the app
needs to store or query data, persist state, or keep per-user data. Triggers on
"database", "Postgres", "Neon", "save data", "store data", "persist", "tables",
"SQL", "query", "migrations".
metadata:
short-description: "Neon Postgres (with a local PGLite fallback) for this template"
user-invocable: false
---
# Neon Postgres
**The database is opt-in** (AGENTS.md §0.5): use it only when the app needs data
that outlives a browser session or is shared across devices. Otherwise ship no
migrations, don't import `@/lib/db`, and keep state in `localStorage` / zustand.
This template ships a ready-made, **dual-mode** database integration:
- **Configured** (env var set, e.g. deployed): real **Neon Postgres**.
- **Not configured** (sandbox live preview): the DB falls back to a local
**PGLite** (embedded WASM Postgres), so the preview always renders. Build
against the `@/lib/db` helper; both modes work with the same API.
Packages are **preinstalled** — do not `npm install` them: `pg` (node-postgres,
the regular Postgres driver) and `@electric-sql/pglite` (local DB fallback).
For **user accounts, sign-in, and reading the current user**, see the separate
**`auth` skill** — this skill is just the database.
## Turning the database on
Set `deploy.database` to `true` in `.grok/app-env.json`:
```json
{ "VITE_AUTH_ENABLED": "false", "deploy": { "database": true } }
```
That is what tells the platform to provision Neon for the deployed app; leave it
`false` and the deploy gets no `DATABASE_URL`, so the app silently runs on a
throwaway PGLite that loses its rows. Shipping `migrations/*.sql`, or sign-in,
provisions one regardless — this flag is for an app that queries `@/lib/db`
without either. It is not a `VITE_` key and never reaches the browser.
## Env vars — do **not** create a `.env` file
**Never write a `.env` / `.env.local` / `.env.example` for the database.** In
the sandbox live preview, leave `DATABASE_URL` unset — `@/lib/db` automatically
uses embedded PGLite. When the app is deployed, the platform injects
`DATABASE_URL` (Neon); you do not provision or write it yourself.
| Var | Where | Purpose |
|---|---|---|
| `DATABASE_URL` | server | Neon connection string when deployed (optional — PGLite fallback if unset) |
Never hardcode it; never expose non-`VITE_` vars to the client.
## Database (server-only)
`@/lib/db` exports `getSql()` and `dbSource`: a **regular Postgres driver**
(node-postgres, `pg`) against `DATABASE_URL`, or a local **PGLite** fallback when
unset. Same API either way — a tagged template (and `.query()`) resolving to
`rows[]`. Call ONLY from a `createServerFn` handler / server loader, never a
client component. Define schema in `migrations/`, not inline.
```ts
import { createServerFn } from "@tanstack/react-start";
import { getSql } from "@/lib/db";
export const listPosts = createServerFn({ method: "GET" }).handler(async () => {
const sql = await getSql();
// Type the row shape — a server fn's return must be provably serializable.
return sql<{ id: number; title: string }>`select id, title from posts order by id desc`;
// or: return sql.query<{ id: number; title: string }>("select id, title from posts where id = $1", [id]);
});
```
**Per-user data (only once the app has sign-in).** A regular driver has full DB
access, so scope **every** query to the authenticated user server-side — never
trust a client-sent id. Use the prewired **`authMiddleware`** to get a verified
`context.userId`, then filter by it. Full pattern (middleware, calling from
client code, fail-closed semantics) is in the **`auth` skill**:
```ts
import { authMiddleware } from "@/lib/auth/middleware";
export const listTodos = createServerFn({ method: "GET" })
.middleware([authMiddleware])
.handler(async ({ context }) => {
const sql = await getSql();
return sql<{ id: number; title: string }>`select id, title from todos where user_id = ${context.userId} order by id desc`;
});
// mutations must scope writes too: `... where id = ${id} and user_id = ${context.userId}`
```
**Without sign-in (the default), do NOT use `authMiddleware` / `requireUserId`.**
The dev user they fall back to is a preview-only convenience. A deployed app's
`VITE_AUTH_ENABLED` is set by the platform, not by this workspace (today the
deployer always sets it to `"true"`), so deployed, both reject every visitor —
and an auth-off app ships no sign-in route for them to recover with. A
database-only app keeps its rows unowned: no `user_id` column, or one literal
constant. Add the middleware as part of turning sign-in on (the `auth` skill's
upgrade steps), and re-scope or drop those rows then.
Unowned rows are world-readable and world-writable through your public server
functions: never persist personal or sensitive data (names, emails, free text
about a person) in this mode, and leave out destructive bulk mutations
(delete-all, overwrite-all) — if the app needs them, propose sign-in instead.
## Migrations
`migrations/*.sql` are the single schema source. They apply to **Neon on deploy**
(`npm run build` runs `db:migrate` against `DATABASE_URL`, so Vercel ships with
the schema ready) and to the **PGLite** preview **automatically on startup**, so
dev matches prod.
Neither applier descends into subdirectories, so the Better Auth schema at
`migrations/auth/0001_auth.sql` is **not** applied unless the app turns sign-in
on and copies it up (**do not edit** it — see the `auth` skill). Put your app's
schema in NEW ordered files starting at `0002`:
```sql
-- migrations/0002_schema.sql — example for a todos app; use YOUR app's tables
create table if not exists todos (
id serial primary key,
user_id text not null,
title text not null,
done boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists todos_user_id_idx on todos (user_id);
```
Never edit an applied file — it is tracked by name in `_migrations` and will not
re-run (add a new file instead; new files apply to the running preview on the
next request). Prefer idempotent statements (`… if not exists`). Tables with
per-user data should carry a `user_id text not null` column (TEXT, not UUID — the
preview dev user id is the string `'dev-user'`).
## Preview ↔ production parity
`getSql()` normalizes result types so both backends return identical, JSON-safe
shapes: `bigint`/`count(*)``number`, `date``'YYYY-MM-DD'` string,
`interval` → text, `numeric` → string. Remaining differences to respect:
- **`bigint` past 2^53 loses precision** as a number — cast `::text` if you
ever need huge integers (row counts are fine).
- **Preview DB is in-memory**: wiped on dev-server restart, single-connection
(no lock contention or concurrent-write conflicts), and loads **no
extensions** — do not `create extension`; stick to core Postgres.
- **Neon's pooled endpoint keeps no session state** — don't rely on `SET`,
`LISTEN/NOTIFY`, or session advisory locks.
- **Keep `user_id` columns `text`** — preview uses `'dev-user'`, production uses
Better Auth's text ids; a `uuid` column breaks preview inserts.
- Deployed Neon queries traverse the network (and may cold-resume) — avoid
N+1 query patterns that feel free against the in-process preview DB.
+102
View File
@@ -0,0 +1,102 @@
---
name: og
description: >
Share-link previews and app identity for apps on *.grok.me: the injector-owned
og:image card, the SVG favicon, and PWA icons for installable apps.
Use when scaffolding, renaming, or restyling the app — and for share /
unfurl / OG / Twitter card questions. A custom 1200×630 card from the app's
own art is the default — games of every kind (DOM board/word games
included), whimsical apps, creative tools, and brand-forward pages; only
plain utilities keep the placeholder. Always run the brand-asset pass as a
`task` subagent and never wait for it.
Triggers on "share", "rename", "app name", "OG", "Open Graph",
"twitter card", "unfurl", "og:image", "og:type", "x:game:image",
"x-banner", "link preview", "social card", "thumbnail", "preview image",
"favicon", "app icon", "PWA", "manifest", "installable", "home screen",
"SEO", "meta description".
metadata:
short-description: "Brand assets: og.jpg card, X feed banner, SVG favicon, PWA icons — always a non-blocking `task` subagent"
user-invocable: false
---
# Share cards, favicon, and app icons
A deployed app (`https://{name}.grok.me`) unfurls with a 1200×630 card; every app (preview included) shows a
favicon in the tab. **Share-card `<meta>` tags are not authored in `__root.tsx`** — the injector
(`scripts/grok-pwa-shared.mjs`) overwrites `og:*` and `twitter:card` on every HTML response. Identity data is
the only thing anyone writes, and the pass writes all of it — dispatching, you seed none of it:
- `src/lib/og/site.json` — not pre-seeded, created only if needed: `{ "title", "type"?: "x:game", "card"?: "custom", "color"?: "RRGGBB" }`; title defaults to the host slug.
- `public/og.jpg` — custom 1200×630 card (optional; placeholder otherwise)
- `public/x-banner.jpg` — games only: 50:11 (1200×264) X feed card
- `public/favicon.svg` — linked from root `head()`; until the pass lands the tab just shows the browser default, which fails nothing
**Extend `__root.tsx`; never replace it wholesale** (auth SSR, redesigns, skill excerpts): dropping the
favicon link ships a blank tab icon no local check catches.
## Decide: which card this app gets
**Default: a custom card** from the app's own art — games of every kind and rendering tech (Canvas/WebGL *and*
DOM board, card, word, puzzle, quiz: a tic-tac-toe grid of divs is still a game), whimsical apps, creative
tools, content- and brand-forward pages. **When in doubt, make the custom card.**
**Plain utility apps only** (converters, CRUD trackers, dashboards, notes/admin — apps whose face is the data)
keep the default `og.grok.me` placeholder: no `public/og.jpg`. Its URL, the `"color"` knob and the rename
rule: `references/placeholder-card.md`.
## `og:type` for games
**A game of any kind** carries `"type": "x:game"` in `src/lib/og/site.json` — the pass writes it, owning that
file. No hostname, never gated on a custom card, never "corrected" to `website`, bare `game`, `twitter:card`
or an invented `x:type`: X's pipeline keys off that exact value. Non-games omit it. Your check, not your
edit — missing it, or missing `public/x-banner.jpg` once a custom card exists, is a **BRAND WARNING**.
## Brand-asset pass: always a subagent, never waited for
**Have the `task` tool? Dispatch this pass as a subagent and never generate card art yourself** — inline it
puts minutes of generation latency in front of the user. As soon as name and palette settle (AGENTS.md §
"Parallel work"), dispatch this prompt verbatim. It is complete — do not open the references below to
enrich it; the pass reads them itself:
> You are the brand-asset pass. Follow the `og` skill, which tells you where to start. App `<NAME>`,
> `og:type` `<TYPE>`, palette `<PALETTE>`. You solely own `public/` brand assets and `src/lib/og/site.json`.
Keep building. Stay sequential only if the user is art-directing or the art to reuse doesn't exist yet.
**Never wait for it.** No `wait_tasks`, and **never `get_task_output` on the brand task**: reading a task's
output consumes it, and a consumed task sends no completion notification, so the card's result — a failure
included — would reach nobody. Answer as soon as the app renders; the pass wakes you later, and that turn is
**one short sentence at most** — one that asks for a republish, since `public/og.jpg` ships in the build
and a card that lands after a publish never reaches the live app on its own:
"Added the share card — publish again if you already did." / "The card failed; the default one stands."
While the pass keeps `/workspace/.grok/og-pending` fresh, brand checks say nothing about the card: in flight
is not a finding. The marker goes stale after 10 minutes, so a very long pass lets the warning through — but
a brand warning while it runs is never a cue to redo its work.
**No `task` tool? Then you are the pass** — build the assets now; nothing else will. Whoever runs it claims
that marker, stages files under `/workspace/.grok/` — never inside `public/`, which `vite build` copies
verbatim into the deployed app — hands it over with `scripts/write-atomic.mjs` so no build reads half a JPEG,
and self-checks with `node scripts/brand-check.mjs --game`.
## Build the assets — the pass reads these, the parent does not
**Dispatching? Do not open them** — the prompt above is complete. One read carries this procedure in your
context every later turn while the subagent does the work anyway.
**You are the pass?** Start at `references/brand-pass.md`, then read
per asset you owe: `references/custom-card.md` for `public/og.jpg`, `references/x-banner.md` for the
games-only `public/x-banner.jpg`, `references/favicon-and-icons.md` for `public/favicon.svg` plus the PWA
raster icons — those only when the user asked for installable/PWA, never invent a manifest. **Hand-author
that SVG, never `imagine_text_to_image`**: it must stay crisp at 16px. Writing `site.json` for a game?
`references/og-type-contract.md` argues the spellings X rejects.
Regenerate on rename or a material identity change — `APP_NAME`, the `site.json` `title` and the baked-in card
title move together. Without `imagine_text_to_image` or the xAI Images API, keep the `og.grok.me` card; never
ship a broken `og:image` URL.
## Not supported
No `/api/og` route, no runtime image renderer, no per-route cards — the card is one static site-wide image
(`public/og.jpg` or the placeholder service). If you add `robots.txt`, never blanket `Disallow: /`: crawlers
must fetch `/` to read the tags.
+53
View File
@@ -0,0 +1,53 @@
# Running the brand-asset pass (the pass's own contract)
Read this when you *are* the pass — dispatched as the brand subagent, or
building the assets inline because no `task` tool exists. The parent keeps
building and never waits, so everything below is yours to get right unobserved.
## 1. Claim the marker, keep it fresh, always release it
`touch /workspace/.grok/og-pending` before generating and again before each
`imagine_*` call — while that marker is fresh `brand-check.mjs` suppresses the
missing-card warnings the parent would otherwise act on, and it goes stale after
10 minutes so a killed pass cannot silence the check forever.
`rm -f /workspace/.grok/og-pending` on every exit path, success or not.
## 2. Hand every file over atomically
`public/og.jpg`, `public/x-banner.jpg` and `src/lib/og/site.json` alike. The
parent may be mid-`npm run build` and would then read a half-written JPEG. Write
to a staged path under `/workspace/.grok/` — never inside `public/`, which
`vite build` copies verbatim into the deployed app, and never on another
filesystem such as `/tmp`, where the hand-over cannot be a rename — then:
```sh
node scripts/write-atomic.mjs /workspace/.grok/og.jpg.tmp public/og.jpg
```
`src/lib/og/site.json` is the only file this pass writes under `src/`. Hand it
over **once**, with the finished card — never a field at a time: its
`"card": "custom"` flag has to land *with* the card and not before, because the
bake trusts the flag on its own and would emit an `og:image` URL for a file that
does not exist.
## 3. Verify your own work, because nobody waits for it
Run the card checks in `custom-card.md`, then `node scripts/brand-check.mjs --game`
(drop `--game` for non-games), which prints a JSON verdict and exits non-zero
on any `BRAND WARNING`. That run judges the files on disk and reports
`"pending": true` — the marker only demotes the parent's gates — and it treats a
missing `public/og.jpg` as a failure whatever kind of app this is, because
producing one is what this pass is normally for.
**The one exception**: a pass launched for a plain utility that keeps the
`og.grok.me` card (favicon, PWA icons, and title only — SKILL.md § "Decide:
which card this app gets") is not there to produce a card, so it adds
`--placeholder-ok` and the missing card is then the expected verdict rather than
a failure:
```sh
node scripts/brand-check.mjs --placeholder-ok
```
Use it only for that launch — passing it on a custom-card app hides the one
thing that pass owes. Report pass or fail in your answer text.
+107
View File
@@ -0,0 +1,107 @@
# Custom card: generate `public/og.jpg`
For prompt-craft, composition, and blind read-back verification, follow the
`imagine` and `game-asset-core` skills — this file owns the **card-specific**
contract only (size, lockup, wiring).
1. **Set the canvas with `aspect_ratio: "16:9"`.** The call looks like
`{ "prompt": "…", "aspect_ratio": "16:9" }` — ratio words in the prompt do
**not** set the canvas. At 2mp, 16:9 renders 1792×1008, so the normalize
below cover-crops to 1200×630 trimming only ~3% vertically, which a
centered title survives. A narrower canvas is what kills titles: from 3:2
the same crop takes **~21% vertically**, straight through the lockup.
Paths:
- **Default — one call with `aspect_ratio: "16:9"`:** `imagine_text_to_image`
with the art + baked title. **Check the output dimensions** via
`read_file` / Pillow on the returned `file_path`; if the ratio missed, reframe or use
the API path.
- **Reframe if needed:** pass the prior `file_path` into `imagine_image_to_image`
with **`aspect_ratio: "16:9"`** and a prompt like "extend the scenery
left and right into a wider frame; keep the title lettering and
central subject exactly as they are".
- **Optional — true 2:1 via the xAI Images API:** `POST
https://api.x.ai/v1/images/generations` with `"aspect_ratio": "2:1"`
and `response_format: "b64_json"` using the injected `XAI_API_KEY`
(see the `xai-api` skill). 2mp 2:1 is 1984×992; normalize then trims
only ~2.4% per side and nothing vertical.
Build the prompt from the app's theme, palette, and characters. If the
app already has a key generated asset (hero sprite, title scene), pass
its `file_path` into `imagine_image_to_image` so the card matches in-game art —
same 16:9 + check-the-output rule applies.
**Last resort only** (no `imagine_text_to_image` and no xAI Images API): stay
on whatever canvas you have and keep the entire title block inside the
**middle half** of the frame height, with the crop-clipping check in
step 6 as the gate.
2. **Bake the title in like a game cover.** Store-page covers (Stardew Valley,
Cuphead) lead with a short stylized logo-type title. Put the exact app name
in quotes in the prompt; 13 strong words; optional short tagline under the
title in smaller lettering (exact words in quotes).
- **Stack multi-word titles** into a two-line lockup ("SKY" over "STRIKE").
- **Center the block both ways** with generous margins — avoid "upper third"
/ percentage placement (models hug the edge).
- **Bound the width**: lettering spans roughly half to two-thirds of the
frame, never border to border.
- **Keep comfortable margins anyway.** From a 16:9 canvas the normalize
below trims only ~3% vertically; from a 2:1 API canvas it trims
nothing vertical and ~2.4% per side. The crop turns destructive when
the canvas comes back off-ratio — an edit that pinned to its input's
ratio, or a model miss — so **check the raw canvas dimensions before
cropping**, and re-ratio first if it isn't ~16:9 or 2:1.
3. **Verify glyphs *and* layout on read-back** (see `imagine` / `game-asset-core`
for the blind-describe loop). On a garble or layout miss, **regenerate with a
corrected prompt** — never try to move a logo with `imagine_image_to_image` (frame
translation / seams). After two failed attempts, ship the card **artwork-only**
(titleless).
**Intentional exception vs `imagine`'s "rebuild text with code" rule:** the
share card is a single static PNG; there is no reliable in-sandbox path to
composite crisp code-drawn lettering onto generative art for this asset, so
a clean titleless card is the correct fallback after two glyph failures.
4. **Normalize to exactly 1200×630 JPEG** with the baked-in ffmpeg (cover-crop —
from 16:9 this shaves ~3% top/bottom; from 2:1 ~2.4% per side and
nothing vertical). **JPEG, not PNG**: the card is
photographic generative art, and a PNG of it lands at 12 MB — heavy
enough that link scrapers (X card previews included) time out or skip the
image, so the card silently fails to unfurl. JPEG at this quality is
~150300 KB with no visible loss at unfurl size:
```sh
ffmpeg -y -i card-raw.jpg \
-vf "scale=1200:630:force_original_aspect_ratio=increase,crop=1200:630" \
-q:v 4 /workspace/.grok/og.jpg.tmp
node scripts/write-atomic.mjs /workspace/.grok/og.jpg.tmp public/og.jpg
```
5. **Tell the injector the card is custom** — set `"card": "custom"` in
`src/lib/og/site.json`, handed over the same way, and keep `public/og.jpg`:
```sh
node scripts/write-atomic.mjs /workspace/.grok/site.json.tmp src/lib/og/site.json
```
Bake also infers custom
from the file if the flag is missing, but brand-check still requires the
field. The injector emits the absolute `https://${host}/og.jpg` URL. Do not
add `og:image` to `__root.tsx`.
6. **Verify before finishing** (Pillow is installed; `ffprobe` is **not**):
```sh
python3 -c "
from PIL import Image; import os
im = Image.open('public/og.jpg')
kb = os.path.getsize('public/og.jpg') // 1024
print(im.size, f'{kb} KB')"
# expect: (1200, 630) and under 600 KB (keeps X and other scrapers
# reliable; target <= 300 KB — if over, bump -q:v up a step and re-encode)
```
**Read back the final `public/og.jpg`, not the pre-crop raw** — the crop
is where clipping happens. This is a **hard gate, not an impression**:
if any title glyph touches a frame edge or is visibly cut, the card is
**rejected** — do not ship it, whatever else is right about it. Fix the
ratio (step 1) or regenerate with the width bound restated; a shipped
decapitated title is worse than the placeholder. Then confirm the card
reads like *this* app at thumbnail size — clear subject, correctly
spelled title (if any), comfortable margins.
@@ -0,0 +1,48 @@
# Favicon and PWA icons
## Favicon: hand-author `public/favicon.svg`
Every app gets one, and it works in live preview immediately (no host needed).
- **Write the SVG by hand — never `imagine_text_to_image`.** It must stay crisp at 16px:
one bold glyph or shape, flat fills from the app's design tokens, a square
`viewBox`, a handful of elements at most. For whimsical apps an emoji-text
SVG is a fine quick win:
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#1a1b26"/>
<text x="50" y="50" font-size="62" text-anchor="middle"
dominant-baseline="central">🥕</text>
</svg>
```
- Wire it in the root `head()`:
```tsx
links: [
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
{ rel: "stylesheet", href: appCss },
],
```
- Verify it renders non-blank and legible small (browser tab in a preview
screenshot, or read back a rasterized copy). Update it when the app's theme
or name changes meaning — it is part of the app's identity, not a set-and-forget.
## PWA icons: only for installable apps
When the app ships a web manifest (the user asked for installable / PWA /
home-screen behavior — do **not** invent a manifest just to have icons), add
raster icons derived from the favicon artwork so the identity stays
consistent:
- `public/icon-192.png` and `public/icon-512.png` — the favicon's glyph on
its tile, rasterized at size. Playwright (baked into the sandbox) can
screenshot the served `/favicon.svg` at a 192/512 viewport; or redraw the
same mark as a flat PNG. Keep it bold and flat — no photographic detail.
- A maskable variant (`"purpose": "maskable"`) needs the glyph inside the
center ~80% safe zone so launcher shapes don't clip it.
- Wire the manifest `icons` array plus `theme_color` / `background_color`
from the app's design tokens, and read the 192 back to confirm it stays
legible.
@@ -0,0 +1,18 @@
# `og:type` — why `x:game`, and the rejected alternatives
The game signal is written as `"type": "x:game"` in `src/lib/og/site.json`; the
platform injector turns it into `<meta property="og:type" content="x:game">`.
This will live forever in every shipped game, so the channel is fixed:
| Option | Verdict |
| --- | --- |
| **`site.json` `"type": "x:game"` → injector emits `<meta property="og:type" content="x:game">`** | **Chosen.** You write the field in `src/lib/og/site.json`; the PWA injector emits the standard OG content-type property. Value is a **namespaced type** (`x:game`) so it cannot be confused with a future global OGP `game` type or bare `website`. X's card pipeline keys off this exact emitted value. Do not put the meta tag in `__root.tsx`. Do not shorten to bare `game`. |
| **`og:type="game"` (no `x:`)** | **Rejected.** Looks like a global OGP type that does not exist on [ogp.me](https://ogp.me/#types); the product contract is the namespaced `x:game` string. |
| **`twitter:card`** | **Wrong layer for the game signal.** Layout only (`summary_large_image`). The PWA injector always emits it — do not treat the `og` skill as the place that adds it. Never use it as the game type. |
| **Separate `x:type` / `x:card` meta properties** | **Do not invent.** Extra properties double surface area (agents forget one of two tags). Namespacing inside `og:type`'s content (`x:game`) is enough. |
X (Twitter) uses `og:type="x:game"` when unfurling `*.grok.me` links to present
the card as a **game** rather than a generic website. This is a product contract
with X's card pipeline — keep the tag, and do not "correct" it to `website`
during refactors. Non-games should omit `og:type` or use `website` (the scraper
default).
@@ -0,0 +1,21 @@
# The placeholder `og.grok.me` card (plain utility apps)
An app with no `public/og.jpg` unfurls with the hosted placeholder:
```
https://og.grok.me/v1/card.png?host={VITE_PUBLIC_HOSTNAME}&title={APP_NAME}
```
The injector builds that URL — do not paste it into `__root.tsx`.
- Optional theme colour: set `"color": "FF4D2E"` in `src/lib/og/site.json`
(6-digit hex, `#` optional). The injector appends `&color=` on the placeholder
URL; a themed URL written into `__root.tsx` is stripped. Custom cards ignore
`color`.
- On rename, update `APP_NAME` (tab title) **and** `site.json` `title` (share
card).
Live preview emits the same tags (the preview `X-Forwarded-Host` is a valid
image host). On publish the request `Host` is enough — do **not** write a `.env`
for it. Card pixels and `site.json` reach the unfurl on the **next deploy**
(identity is baked at `vite build`).
+41
View File
@@ -0,0 +1,41 @@
# X feed card: `public/x-banner.jpg` (50:11)
**Games also ship a second, much wider card** that X uses in the feed (the
link-preview `og.jpg` is still required — this does not replace it). Write
`public/x-banner.jpg` at **exactly 50:11** (1200×264 — 1200 is the standard
max card width on the web, same as `og.jpg`). The injector emits
`x:game:image` (1200×264) from that file when the request has a public host.
Do **not** invent `x:game:og` or overload `og:image`. Do not add
`x:game:image` to `__root.tsx`.
Same JPEG / size discipline as `og.jpg` (keep it well under 600 KB).
**Custom `public/x-banner.jpg` — games only.** Use `imagine_text_to_image`
(or `imagine_image_to_image`) to generate a custom banner **only when the
app is a game**. Non-games omit the file — the injector then emits no
`x:game:image`. Do not paste `og.grok.me/v1/banner.png` (or any
`x:game:image`) into `__root.tsx`; the injector strips those tags.
Generate the art at 50:11 (`aspect_ratio: "50:11"` on
`imagine_text_to_image` — that ratio is on the Imagine schema; do **not**
substitute 16:9 and crop). Then normalize:
```sh
ffmpeg -y -i banner-raw.jpg \
-vf "scale=1200:264:force_original_aspect_ratio=increase,crop=1200:264" \
-q:v 4 /workspace/.grok/x-banner.jpg.tmp
node scripts/write-atomic.mjs /workspace/.grok/x-banner.jpg.tmp public/x-banner.jpg
```
**Safe lockup.** Unlike the centered `og.jpg` card, keep title, tagline, and
other critical content inside the **left-most 50% × top-most 80%** of the
finished 1200×264. Feed chrome overlays the **right ~25%** and **bottom
~20%**, so lettering there clashes. Raise the lockup above the midline (do
not vertically center it); comfortable left and top margins; never
edge-hug: a 50:11 frame decapitates edge-hugging lettering even faster than
the 1200×630 card. Scenery and characters may extend into those overlay
strips. Prompt Imagine in visual language — "title lockup in the left half,
sitting above the midline, empty strip along the bottom edge" — not
percentages. Reuse the link-cover art when you can (reframe the same scene
wider rather than inventing a second identity). Verify the 1200×264 JPEG
the same way as `og.jpg` (dimensions + under 600 KB + no clipped title).
Reject if any title or tagline glyph sits in the right half or the bottom
fifth of the frame.
+67
View File
@@ -0,0 +1,67 @@
---
name: threejs
description: >
Official Three.js API and TSL (Three.js Shading Language) reference for LLM
code generation. Load when writing or debugging three.js / WebGL / WebGPU /
custom materials / shaders / GLTF / advanced three APIs beyond basic game
loop/controls. Prefer building-games for game correctness (loop, WASD,
camera, orientation); use this skill for full API/TSL depth. Triggers on
"three.js", "threejs", "WebGPU", "TSL", "NodeMaterial", "shader", "GLTF",
"MeshStandard", "OrbitControls", "WebGLRenderer".
metadata:
short-description: "Three.js + TSL full API (official llms-full reference)"
user-invocable: false
---
# Three.js (official LLM reference)
This skill vendors the **official** Three.js LLM documentation pack so the agent
can generate correct modern three.js without inventing outdated CDN/r128 APIs.
## Stack adaptation (this app builder)
You are in a **TanStack Start + React** workspace, not a bare HTML page:
| Official doc pattern | Do this here instead |
| --- | --- |
| `<script type="importmap">` + CDN three | **`npm install three`** (+ `@types/three` if needed); import from `"three"` / `"three/addons/…"` |
| Raw HTML canvas bootstrap | Prefer **@react-three/fiber + drei** for games/UI integration (`building-games` + `3d-libs.md`) |
| Standalone `WebGLRenderer` demo | Fine for a self-contained canvas module; still install three via npm so Vercel build has it |
| Always “latest” CDN version | Pin via **package.json** so dev and deploy match |
**three is not preinstalled** — add it with npm and leave it in `package.json`.
## When to load what
1. **Game / interactive 3D product** → start with **`building-games`** (loop, controls, orientation, camera, first-run, steer sign).
2. **R3F / drei / rapier wiring**`building-games/references/3d-libs.md`.
3. **Deep three API, TSL, WebGPU, materials, loaders, postprocessing** → load
**`references/llms-full.txt`** (this skills full official dump).
Do **not** load `llms-full.txt` for simple 2D canvas games (Pong, tetris, etc.).
## Full reference
**Read on demand:**
```text
references/llms-full.txt
```
Source: https://threejs.org/docs/llms-full.txt (pinned copy for offline sandbox use).
Contents include: modern imports, WebGLRenderer vs WebGPURenderer, TSL complete
reference, NodeMaterial, loaders, post-processing, compute, and API tables.
## Quick defaults for this product
- Prefer **WebGLRenderer** (or R3F default) unless the user needs TSL/WebGPU compute.
- Cap pixel ratio (`renderer.setPixelRatio(Math.min(devicePixelRatio, 2))` or R3F `dpr={[1,2]}`).
- Dispose geometries/materials/textures on teardown (three does not GC GPU resources).
- For games: still obey **`building-games`** control and orientation self-tests.
## Finish check
- three (and R3F stack if used) is in `package.json` and imports resolve.
- No r128 / cdnjs script-tag patterns.
- If TSL/WebGPU used: materials and imports match `llms-full` (node materials, `await renderer.init()`).
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 0x0funky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+236
View File
@@ -0,0 +1,236 @@
---
name: video2dsprite
description: >
Grok Build only. Turn a 2D character still into denser animation sprites via
imagine_text_to_image base → imagine_image_to_video (6s/10s run-in-place) → ffmpeg
frames → magenta chroma-key → dense sampled strips/grids/GIFs. Use when the
user wants video-to-sprite, smoother run/walk cycles, or denser intermediate
poses. Prefer generate2dsprite for crisp production pixel sheets. Triggers on
"video to sprite", "imagine_image_to_video sprite", "dense walk cycle", "smooth run
animation from video".
metadata:
short-description: "Video→dense sprites (imagine_image_to_video + chroma postprocess)"
user-invocable: false
---
# Video2dsprite (Grok Build only)
Convert a **base 2D character image** into **dense animation sprites** using Grok Build's native video tools.
## App-builder / Grok environment
| Item | Value |
| --- | --- |
| Skill dir | `.grok/skills/video2dsprite/` |
| Scripts | `python3 .grok/skills/video2dsprite/scripts/video2dsprite.py …` |
| Video tools | `imagine_image_to_video` — animate one base `file_path` (verify present) |
| Inspect | `read_file` on stills/frames; report paths for videos |
| Deps | ffmpeg + Pillow + numpy (preinstalled in app-builder image) |
| Output home | `assets/sprites/video2dsprite/<name>/` under `/workspace` |
| Default | Prefer **`generate2dsprite`** for production heroes; this is the denser-motion path |
```text
base still → imagine_image_to_video (in-place motion) → extract frames → chroma key → sample/normalize → strip / grid / GIF
```
## Platform gate (read first)
| Runtime | Supported? |
| --- | --- |
| **Grok Build** (xAI) | **Yes** — requires an image generator + an image→video tool |
| Codex / Claude / other agents | **No** — they lack Grok video tools. Tell the user this skill is Grok Build only and offer `$generate2dsprite` instead |
**Gate on the capability, not the exact tool name.** Image generation appears
as `imagine_text_to_image` / `imagine_image_to_image`; video as `imagine_image_to_video`
or (legacy) `generate_video`. Use whichever pair your tool list has. Stop and
explain only when you have no image→video tool at all. Do not fake motion with
code-drawn frames.
This skill is an **optional denser-motion path**. It does **not** replace `$generate2dsprite`:
| Use `$generate2dsprite` when… | Use `$video2dsprite` when… |
| --- | --- |
| Crisp pixel sheets, fixed grids, identity-critical heroes | User wants denser intermediate poses / smoother feeling loops |
| Attack/cast body sheets, prop packs, engine atlases | Experimenting with video-sourced run/walk/idle motion |
| Production default for most game sprites | User explicitly asks for video → frames → sprites |
Video softens pixels, drifts identity, and leaves chroma fringes. Always QC; for production heroes, prefer `$generate2dsprite` unless the user wants the video look.
## Parameters
Infer from the user request:
- `subject`: character / creature description, or path to existing still
- `action`: `run` | `walk` | `idle` | `attack` | custom motion phrase
- `view`: usually `side` (side-scroller). `topdown` is harder — warn and keep camera locked
- `duration`: `6` (default) or `10` seconds
- `frame_counts`: which denser sets to export, default `8,16,24,48`
- `cell_size`: output sprite cell, default `128`
- `anchor`: `feet` (default for side locomotion) | `center`
- `bg`: solid `#FF00FF` (required for chroma)
- `name`: output slug
- `out_dir`: working folder (default `./sprites/video2dsprite/<name>/` or project-relative)
## Agent rules
1. **Grok-only.** Refuse on non-Grok runtimes with a short explanation + `$generate2dsprite` alternative.
2. **Still → video, never text-to-video alone.** Stage frame 1 as a clean still with `imagine_text_to_image` (from a prompt, or from a reference `file_path`). Then call `imagine_image_to_video` with that still's `file_path`.
3. **In-place motion.** Prompt for run/walk **in place** facing a fixed direction. No camera pan, no background scroll, no scene change. Subject stays roughly centered.
4. **Solid magenta background** on the base and preserved in the video prompt (`#FF00FF` / pure magenta). Required for flood-fill chroma.
5. **Do not invent art with PIL/Canvas.** Base art comes from `imagine_text_to_image` or a user/local still. Scripts only postprocess.
6. **Do not put experimental outputs into the game** unless the user asks to integrate.
7. **Prefer one locomotion cycle for game use.** Dense sample across a full 6s multi-cycle clip is fine for previews; for engine sheets, optionally re-sample a single cycle (1216 frames) after visual QC.
8. **Report absolute paths** of video, cleaned frames, strips, and preview GIFs when done.
## Workflow
### 1. Plan
Pick the smallest useful run:
- Side-view run/walk loop → this skill
- Multi-action hero kit → still use `$generate2dsprite` per action; only use video for locomotion if requested
- FX / projectile / prop packs → `$generate2dsprite`, not video
Create:
```text
<out_dir>/
base/
video/
frames-raw/
frames-clean/
sprite/ # default 8-frame set + denser x16/x24/x48
prompt-used.txt
pipeline-meta.json
README.txt
```
### 2. Build the base still
Options:
- **A. Existing sprite:** read the image, composite onto solid `#FF00FF` if needed, then pass that sandbox path to `imagine_image_to_image` / `imagine_image_to_video`
- **B. New character:** `imagine_text_to_image` with solid magenta background, full body, side view, centered
- **C. Match reference:** `imagine_image_to_image` with the reference `file_path`, moving it onto magenta and preserving identity
Base requirements:
- Full body visible, generous magenta margin
- Side view for run/walk (profile or 3/4 side), feet near bottom third
- Same art style as the rest of the project when a reference exists
- No text, UI, watermark, or second character
Copy the returned `file_path` and save as
`<out_dir>/base/<name>-base.png`; keep the base `file_path` for the video call.
Write the exact image prompt into `prompt-used.txt`.
### 3. Animate with `imagine_image_to_video`
Call Grok `imagine_image_to_video`:
- source path: `<base still file_path>` (sandbox path returned by T2I / I2I)
- `duration`: `6` (default) or `10`
- `resolution_name`: `480p` unless user asks `720p`
- `prompt`: one short present-tense shot (see [references/prompt-rules.md](references/prompt-rules.md))
Mandatory motion constraints in the prompt:
- Subject runs/walks **in place** (treadmill style)
- Camera **locked** — no pan, zoom, or orbit
- Background stays **flat solid magenta**
- Identity, costume, palette stable for the whole shot
- Single continuous action only
Copy the returned video `file_path` to
`<out_dir>/video/<name>-<duration>s.mp4`.
If video tools are unavailable, stop (platform gate).
### 4. Extract + chroma + sample (local script)
Run the processor (ffmpeg + Pillow + numpy):
```bash
python3 .grok/skills/video2dsprite/scripts/video2dsprite.py process \
--video <out_dir>/video/<name>-6s.mp4 \
--out-dir <out_dir> \
--name <name> \
--frame-counts 8,16,24,48 \
--cell-size 128 \
--body-height 100 \
--foot-y 118 \
--fps 0
```
Notes:
- `--fps 0` = extract every decoded frame (use source fps)
- Magenta flood-fill from corners + despill
- Even sampling for each count in `--frame-counts`
- Feet-normalized cells, horizontal strip, grid, loop GIF per count
Optional: only re-sample denser sets from existing cleaned frames:
```bash
python3 .grok/skills/video2dsprite/scripts/video2dsprite.py sample \
--clean-dir <out_dir>/frames-clean \
--out-dir <out_dir> \
--frame-counts 16,24,48 \
--cell-size 128
```
### 5. QC
Visually check:
- [ ] Preview GIF loops without huge pops
- [ ] Magenta gone (no solid pink blocks); fringe acceptable or re-key
- [ ] Feet stay on a stable baseline (no hop from bad crop)
- [ ] Identity roughly stable (face/clothes not morphing every frame)
- [ ] Action is in-place (not sliding out of frame)
- [ ] For game use: pick one count (often **16 or 24**) or cut one true cycle
If identity drifts hard or pixels are too soft, fall back to `$generate2dsprite` for production sheets and keep the video set as motion reference only.
### 6. Deliver
Report paths only (unless user asked to wire into a game):
- Video: `video/*.mp4`
- Dense sprites: `sprite/x16|x24|x48/`
- Strips / grids / GIFs: `sprite/run-strip-N.png`, `run-grid-N.png`, `run-preview-N.gif`
- Meta: `pipeline-meta.json`
Do **not** modify game code unless requested.
## Defaults
- Duration: **6s**
- Action: **side run in place**, facing right
- Export counts: **8, 16, 24, 48**
- Cell: **128²**, body height ~100, feet at y≈118
- Background: **#FF00FF**
- Prefer single-asset `imagine_image_to_video` over multi-ref (compose multi-ref with `imagine_text_to_image` first if needed)
## Tradeoffs (tell the user once)
**Pros:** denser intermediates → often feels smoother than 48 discrete gen poses.
**Cons:** softer pixels, identity drift, chroma fringe, multi-cycle 6s clips are not a single perfect loop, heavier assets.
**Rule of thumb:** 8→16→24 usually gains smoothness; 48 is often diminishing returns; 145 raw frames are for sampling, not all for runtime.
## Resources
- [references/prompt-rules.md](references/prompt-rules.md) — base still + video prompts
- [references/pipeline.md](references/pipeline.md) — folder layout, ffmpeg, sampling strategy
- [scripts/video2dsprite.py](scripts/video2dsprite.py) — extract, chroma, normalize, export
## Relationship to other skills
- `$generate2dsprite` — primary sheet pipeline (Codex + Grok when image gen exists)
- `$generate2dmap` — maps; not used here
- `$video2dsprite`**Grok Build exclusive** motion densification path
- `$game-asset-core` / `$game-animation-frames` — loop/flip-test/motion laws and
engine-ready defaults; still use this skills scripts for sandbox execution
(magenta base + chroma), not a freeform background color
+6
View File
@@ -0,0 +1,6 @@
# Source
Vendored from [agent-sprite-forge](https://github.com/0x0funky/agent-sprite-forge) (`53dce6055984c610d833e77887939cbd0fb1c92b`).
MIT License — see `LICENSE`.
Adapted for Grok Build / app-builder sandbox (image tool paths, `read_file` for image inspection, workspace-relative script paths).
@@ -0,0 +1,90 @@
# Video2dsprite pipeline
## End-to-end
```text
1. base still imagine_text_to_image / existing PNG on #FF00FF
2. video imagine_image_to_video (6s default, 10s optional)
3. frames-raw ffmpeg decode all frames (or fixed fps)
4. frames-clean magenta flood-fill + light despill → RGBA
5. sample even indices for N in {8,16,24,48}
6. normalize crop alpha bbox → scale body height → feet line
7. export sprite_XX.png, strip, grid, preview GIF
8. meta pipeline-meta.json + README.txt
```
## Suggested folder layout
```text
<out_dir>/
base/<name>-base.png
video/<name>-6s.mp4
frames-raw/frame_0001.png ...
frames-clean/clean_0000.png ...
sprite/
sprite_01.png ... # default small set if requested
x16/sprite_01.png ...
x24/...
x48/...
run-strip-8.png
run-strip-16.png
run-preview-24.gif
...
prompt-used.txt
pipeline-meta.json
README.txt
```
## ffmpeg extract
Processor shells out to `ffmpeg` when available:
```bash
ffmpeg -y -i video.mp4 -vsync 0 frames-raw/frame_%04d.png
```
If ffmpeg is missing, fail with a clear install message. Do not invent frames.
## Chroma key
1. Treat near-magenta pixels as key candidates (hue distance + high magenta channel).
2. Flood-fill from image corners so interior magenta-ish costume bits are less likely to vanish.
3. Despill residual pink fringes toward neutral/transparent.
4. Write RGBA PNGs.
## Sampling
Even spacing including first and last:
```text
idx[i] = round(i * (total - 1) / (want - 1)) for i in 0..want-1
```
Export multiple `want` values in one run so the user can compare smoothness vs softness.
## Normalize (feet anchor)
1. Alpha bbox of cleaned frame
2. Scale so content height ≈ `body_height` (default 100 in a 128 cell)
3. Paste so bottom of content sits at `foot_y` (default 118)
4. Center horizontally
For `center` anchor, place bbox center at cell center instead.
## GIF duration defaults
| Frames | ms / frame (approx) |
| --- | --- |
| 8 | 80 |
| 16 | 60 |
| 24 | 40 |
| 48 | 25 |
Goal: roughly 0.61.2s visual loop for previews (not necessarily matching source video realtime).
## When not to use this pipeline
- Need hard pixel edges and fixed multi-row grids → `$generate2dsprite`
- Map props / tilesets → `$generate2dmap` + `$generate2dsprite`
- Non-Grok agent without `imagine_image_to_video`
- User wants production-perfect hero kit with many actions — video path is locomotion experiment first
@@ -0,0 +1,63 @@
# Video2dsprite prompt rules
## Base still (`imagine_text_to_image`)
Required:
- Solid flat background `#FF00FF` (pure magenta), no gradient, no floor shadow if possible
- Full body, centered, generous margin on all sides
- Side or 3/4-side view for run/walk
- Same scale and costume the game already uses when a reference exists
- No text, UI, watermark, speech bubbles, second character
Good base pattern:
```text
Side-view full-body 2D game sprite of <subject>, <style>, standing ready pose,
facing right, centered in frame, feet near lower third, solid flat magenta
background #FF00FF only, no ground, no shadow, no text, crisp readable silhouette.
```
If matching a project sprite: use `imagine_image_to_image` with the existing frame's `file_path` and only change pose/background to magenta if needed. Prefer compositing a known good frame onto magenta in code when the art already exists.
## Video (`imagine_image_to_video`)
Write **one short present-tense shot** (12 sentences). Constraints:
| Do | Don't |
| --- | --- |
| Run/walk **in place** (treadmill) | Travel across the screen |
| Locked camera | Pan, zoom, orbit, handheld |
| Keep solid magenta background | Scenic BG, ground scroll, particles filling frame |
| Single continuous action | Combo attacks + movement + camera |
| Stable identity/clothes | Costume change mid-clip |
Run example:
```text
The same chibi ninja character runs in place facing right with a classic side-scroller
stride, arms trailing slightly back, body centered, camera locked, solid flat magenta
background only.
```
Walk / idle examples:
```text
The character walks in place facing right with a steady side-view walk cycle, camera locked, solid magenta background.
```
```text
The character idles in place with a subtle breathing bob and weight shift, camera locked, solid magenta background.
```
Attack (use carefully — identity drift is higher):
```text
The character performs a single short punch combo in place facing right, camera locked, solid magenta background, no screen-filling FX.
```
## Sampling guidance (post-video)
- Export multiple densities: 8 / 16 / 24 / 48 for comparison GIFs
- For engine integration: pick one smooth cycle (~1216 frames) after watching previews
- If the 6s clip contains multiple run cycles, denser even sampling across the whole clip can look like a long multi-cycle animation — that is OK for previews, but for a game loop re-sample one cycle region manually if needed
@@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""Postprocess Grok imagine_image_to_video clips into dense 2D sprites.
Pipeline steps (deterministic only no creative generation):
extract ffmpeg frames from mp4
clean magenta flood-fill chroma + light despill
sample even-index frame sets + feet/center normalize
process extract + clean + sample in one shot
This skill is designed for Grok Build (imagine_text_to_image + imagine_image_to_video).
The script itself only needs ffmpeg, Pillow, and numpy.
"""
from __future__ import annotations
import argparse
import json
import math
import shutil
import subprocess
import sys
from collections import deque
from pathlib import Path
from typing import Sequence
import numpy as np
from PIL import Image
MAGENTA = np.array([255, 0, 255], dtype=np.float32)
def _ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def _parse_counts(text: str) -> list[int]:
counts: list[int] = []
for part in text.split(","):
part = part.strip()
if not part:
continue
n = int(part)
if n < 1:
raise ValueError(f"frame count must be >= 1, got {n}")
counts.append(n)
if not counts:
raise ValueError("at least one frame count required")
return counts
def sample_indices(n_total: int, n_want: int) -> list[int]:
if n_total <= 0:
return []
if n_want >= n_total:
return list(range(n_total))
if n_want == 1:
return [0]
return [int(round(i * (n_total - 1) / (n_want - 1))) for i in range(n_want)]
def extract_frames(video: Path, out_dir: Path, fps: float = 0.0) -> list[Path]:
_ensure_dir(out_dir)
for old in out_dir.glob("frame_*.png"):
old.unlink()
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise RuntimeError("ffmpeg not found on PATH. Install ffmpeg to extract video frames.")
pattern = str(out_dir / "frame_%04d.png")
cmd = [ffmpeg, "-y", "-i", str(video)]
if fps and fps > 0:
cmd += ["-vf", f"fps={fps}"]
else:
cmd += ["-vsync", "0"]
cmd.append(pattern)
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError("ffmpeg failed:\n" + (proc.stderr or proc.stdout or "unknown error"))
frames = sorted(out_dir.glob("frame_*.png"))
if not frames:
raise RuntimeError(f"no frames extracted into {out_dir}")
return frames
def _near_magenta_mask(rgb: np.ndarray, dist: float = 55.0) -> np.ndarray:
"""rgb: HxWx3 uint8 → bool mask of keyable magenta-ish pixels."""
f = rgb.astype(np.float32)
# Distance to pure magenta in RGB.
d = np.linalg.norm(f - MAGENTA, axis=2)
# Also catch bright pinks: high R+B, low G relative.
r, g, b = f[:, :, 0], f[:, :, 1], f[:, :, 2]
pinkish = (r > 160) & (b > 160) & (g < 140) & ((r + b) / 2 - g > 40)
return (d <= dist) | pinkish
def chroma_key_rgba(im: Image.Image, dist: float = 55.0) -> Image.Image:
"""Flood-fill magenta from corners, despill edges, return RGBA."""
rgba = im.convert("RGBA")
arr = np.array(rgba)
rgb = arr[:, :, :3]
h, w = rgb.shape[:2]
key = _near_magenta_mask(rgb, dist=dist)
visited = np.zeros((h, w), dtype=bool)
q: deque[tuple[int, int]] = deque()
for y, x in ((0, 0), (0, w - 1), (h - 1, 0), (h - 1, w - 1)):
if key[y, x]:
visited[y, x] = True
q.append((x, y))
# Also seed along edges where magenta is present.
for x in range(w):
for y in (0, h - 1):
if key[y, x] and not visited[y, x]:
visited[y, x] = True
q.append((x, y))
for y in range(h):
for x in (0, w - 1):
if key[y, x] and not visited[y, x]:
visited[y, x] = True
q.append((x, y))
while q:
x, y = q.popleft()
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
if 0 <= nx < w and 0 <= ny < h and not visited[ny, nx] and key[ny, nx]:
visited[ny, nx] = True
q.append((nx, ny))
out = arr.copy()
out[visited, 3] = 0
# Light despill on remaining near-magenta fringe (keep RGB, reduce alpha).
fringe = key & ~visited
if fringe.any():
# Pull toward less magenta and soften alpha.
fr = out[fringe].astype(np.float32)
r, g, b, a = fr[:, 0], fr[:, 1], fr[:, 2], fr[:, 3]
spill = np.maximum(0.0, (r + b) / 2.0 - g)
factor = np.clip(1.0 - spill / 180.0, 0.15, 1.0)
fr[:, 0] = np.clip(r - spill * 0.35, 0, 255)
fr[:, 2] = np.clip(b - spill * 0.35, 0, 255)
fr[:, 1] = np.clip(g + spill * 0.15, 0, 255)
fr[:, 3] = np.clip(a * factor, 0, 255)
out[fringe] = fr.astype(np.uint8)
# Fully transparent where alpha is 0.
out[out[:, :, 3] == 0, :3] = 0
return Image.fromarray(out, "RGBA")
def content_bbox(im: Image.Image, alpha_min: int = 32) -> tuple[int, int, int, int] | None:
arr = np.array(im.convert("RGBA"))
mask = arr[:, :, 3] > alpha_min
if not mask.any():
return None
ys, xs = np.where(mask)
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
def normalize_sprite(
im: Image.Image,
cell: int = 128,
body_height: int = 100,
foot_y: int = 118,
anchor: str = "feet",
) -> Image.Image:
bb = content_bbox(im)
canvas = Image.new("RGBA", (cell, cell), (0, 0, 0, 0))
if not bb:
return canvas
crop = im.crop(bb)
cw, ch = crop.size
if ch <= 0 or cw <= 0:
return canvas
scale = body_height / float(ch)
nw = max(1, int(round(cw * scale)))
nh = max(1, int(round(ch * scale)))
if nw > cell - 4:
scale = (cell - 4) / float(cw)
nw = max(1, int(round(cw * scale)))
nh = max(1, int(round(ch * scale)))
if nh > cell - 4:
scale = (cell - 4) / float(ch)
nw = max(1, int(round(cw * scale)))
nh = max(1, int(round(ch * scale)))
resized = crop.resize((nw, nh), Image.Resampling.LANCZOS)
if anchor == "center":
x = (cell - nw) // 2
y = (cell - nh) // 2
else:
x = (cell - nw) // 2
y = foot_y - nh
if y < 0:
y = 0
if y + nh > cell:
y = max(0, cell - nh)
canvas.paste(resized, (x, y), resized)
return canvas
def clean_frames(
raw_dir: Path,
clean_dir: Path,
dist: float = 55.0,
) -> list[Path]:
_ensure_dir(clean_dir)
raws = sorted(raw_dir.glob("frame_*.png"))
if not raws:
raise RuntimeError(f"no raw frames in {raw_dir}")
outs: list[Path] = []
for i, path in enumerate(raws):
im = Image.open(path)
cleaned = chroma_key_rgba(im, dist=dist)
out = clean_dir / f"clean_{i:04d}.png"
cleaned.save(out)
outs.append(out)
if (i + 1) % 25 == 0 or i + 1 == len(raws):
print(f" cleaned {i + 1}/{len(raws)}")
return outs
def build_exports(
sprites: Sequence[Image.Image],
out_sprite_dir: Path,
tag: str,
n_frames: int,
gif_ms: int | None = None,
) -> dict:
_ensure_dir(out_sprite_dir)
sub = _ensure_dir(out_sprite_dir / tag) if tag else out_sprite_dir
paths = []
for i, sp in enumerate(sprites):
p = sub / f"sprite_{i + 1:02d}.png"
sp.save(p)
paths.append(str(p))
size = sprites[0].size[0]
strip = Image.new("RGBA", (size * len(sprites), size), (0, 0, 0, 0))
for i, sp in enumerate(sprites):
strip.paste(sp, (i * size, 0), sp)
strip_path = out_sprite_dir / f"run-strip-{n_frames}.png"
strip.save(strip_path)
cols = 8 if n_frames >= 16 else 4
rows = int(math.ceil(len(sprites) / cols))
grid = Image.new("RGBA", (size * cols, size * rows), (0, 0, 0, 0))
for i, sp in enumerate(sprites):
r, c = divmod(i, cols)
grid.paste(sp, (c * size, r * size), sp)
grid_path = out_sprite_dir / f"run-grid-{n_frames}.png"
grid.save(grid_path)
if gif_ms is None:
if n_frames >= 40:
gif_ms = 25
elif n_frames >= 20:
gif_ms = 40
elif n_frames >= 12:
gif_ms = 60
else:
gif_ms = 80
frames_gif = []
for sp in sprites:
bg = Image.new("RGBA", sp.size, (30, 30, 40, 255))
bg.paste(sp, (0, 0), sp)
frames_gif.append(bg.convert("P", palette=Image.ADAPTIVE, colors=255))
gif_path = out_sprite_dir / f"run-preview-{n_frames}.gif"
frames_gif[0].save(
gif_path,
save_all=True,
append_images=frames_gif[1:],
duration=gif_ms,
loop=0,
disposal=2,
)
# Legacy alias for 8-frame default
if n_frames == 8:
alias = out_sprite_dir / "run-preview.gif"
shutil.copy2(gif_path, alias)
return {
"count": n_frames,
"tag": tag,
"sprites": paths,
"strip": str(strip_path),
"grid": str(grid_path),
"gif": str(gif_path),
"gif_ms": gif_ms,
}
def sample_and_export(
clean_dir: Path,
out_dir: Path,
frame_counts: Sequence[int],
cell: int = 128,
body_height: int = 100,
foot_y: int = 118,
anchor: str = "feet",
) -> dict:
cleans = sorted(clean_dir.glob("clean_*.png"))
if not cleans:
raise RuntimeError(f"no cleaned frames in {clean_dir}")
sprite_dir = _ensure_dir(out_dir / "sprite")
n_total = len(cleans)
results = []
for n_want in frame_counts:
idxs = sample_indices(n_total, n_want)
sprites = []
for idx in idxs:
im = Image.open(cleans[idx]).convert("RGBA")
sprites.append(
normalize_sprite(
im,
cell=cell,
body_height=body_height,
foot_y=foot_y,
anchor=anchor,
)
)
tag = f"x{n_want}" if n_want != 8 else ""
# Always also write under xN for consistency when n!=8;
# for 8, write both root sprites and optional x8.
if n_want == 8:
# root-level sprite_01..08 for backwards compat
info = build_exports(sprites, sprite_dir, tag="", n_frames=n_want)
# also x8 folder
build_exports(sprites, sprite_dir, tag="x8", n_frames=n_want)
else:
info = build_exports(sprites, sprite_dir, tag=tag, n_frames=n_want)
info["indices"] = idxs
results.append(info)
print(f"exported {n_want} frames → {info['gif']}")
return {"total_clean": n_total, "sets": results}
def write_readme(out_dir: Path, meta: dict) -> None:
lines = [
"Video2dsprite output (Grok Build pipeline)",
"==========================================",
"base/ base still on #FF00FF",
"video/ imagine_image_to_video clip",
"frames-raw/ decoded frames",
"frames-clean/ chroma-keyed RGBA frames",
"sprite/ sampled normalized sprites + strips/grids/GIFs",
"pipeline-meta.json",
"",
"This folder was produced for Grok Build (imagine_text_to_image + imagine_image_to_video).",
"Codex/other agents cannot run the video step; they can still re-sample",
"existing frames with: python video2dsprite.py sample --clean-dir ...",
"",
json.dumps(meta, indent=2),
"",
]
(out_dir / "README.txt").write_text("\n".join(lines), encoding="utf-8")
def cmd_extract(args: argparse.Namespace) -> int:
frames = extract_frames(Path(args.video), Path(args.out_dir), fps=args.fps)
print(f"extracted {len(frames)} frames → {args.out_dir}")
return 0
def cmd_clean(args: argparse.Namespace) -> int:
outs = clean_frames(Path(args.raw_dir), Path(args.out_dir), dist=args.dist)
print(f"cleaned {len(outs)} frames → {args.out_dir}")
return 0
def cmd_sample(args: argparse.Namespace) -> int:
counts = _parse_counts(args.frame_counts)
meta = sample_and_export(
clean_dir=Path(args.clean_dir),
out_dir=Path(args.out_dir),
frame_counts=counts,
cell=args.cell_size,
body_height=args.body_height,
foot_y=args.foot_y,
anchor=args.anchor,
)
out = Path(args.out_dir)
full = {
"mode": "sample",
"clean_dir": str(Path(args.clean_dir).resolve()),
**meta,
}
(out / "pipeline-meta.json").write_text(json.dumps(full, indent=2), encoding="utf-8")
write_readme(out, full)
print("sample done")
return 0
def cmd_process(args: argparse.Namespace) -> int:
out = Path(args.out_dir)
raw_dir = out / "frames-raw"
clean_dir = out / "frames-clean"
video = Path(args.video)
if not video.is_file():
raise FileNotFoundError(video)
print(f"extract {video}")
frames = extract_frames(video, raw_dir, fps=args.fps)
print(f"clean {len(frames)} frames")
clean_frames(raw_dir, clean_dir, dist=args.dist)
counts = _parse_counts(args.frame_counts)
print(f"sample counts={counts}")
meta_sample = sample_and_export(
clean_dir=clean_dir,
out_dir=out,
frame_counts=counts,
cell=args.cell_size,
body_height=args.body_height,
foot_y=args.foot_y,
anchor=args.anchor,
)
meta = {
"skill": "video2dsprite",
"platform": "Grok Build (imagine_image_to_video required for generation step)",
"name": args.name,
"video": str(video.resolve()),
"out_dir": str(out.resolve()),
"raw_frames": len(frames),
"chroma_dist": args.dist,
"cell_size": args.cell_size,
"body_height": args.body_height,
"foot_y": args.foot_y,
"anchor": args.anchor,
**meta_sample,
}
(out / "pipeline-meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
write_readme(out, meta)
print("process done")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Video → dense 2D sprite postprocessor")
sub = p.add_subparsers(dest="command", required=True)
def add_common_sample(sp: argparse.ArgumentParser) -> None:
sp.add_argument("--frame-counts", default="8,16,24,48")
sp.add_argument("--cell-size", type=int, default=128)
sp.add_argument("--body-height", type=int, default=100)
sp.add_argument("--foot-y", type=int, default=118)
sp.add_argument("--anchor", choices=("feet", "center"), default="feet")
pe = sub.add_parser("extract", help="ffmpeg extract frames")
pe.add_argument("--video", required=True)
pe.add_argument("--out-dir", required=True)
pe.add_argument("--fps", type=float, default=0.0, help="0 = all frames")
pe.set_defaults(func=cmd_extract)
pc = sub.add_parser("clean", help="chroma-key raw frames")
pc.add_argument("--raw-dir", required=True)
pc.add_argument("--out-dir", required=True)
pc.add_argument("--dist", type=float, default=55.0)
pc.set_defaults(func=cmd_clean)
ps = sub.add_parser("sample", help="sample cleaned frames into sprite sets")
ps.add_argument("--clean-dir", required=True)
ps.add_argument("--out-dir", required=True)
add_common_sample(ps)
ps.set_defaults(func=cmd_sample)
pp = sub.add_parser("process", help="extract + clean + sample")
pp.add_argument("--video", required=True)
pp.add_argument("--out-dir", required=True)
pp.add_argument("--name", default="clip")
pp.add_argument("--fps", type=float, default=0.0)
pp.add_argument("--dist", type=float, default=55.0)
add_common_sample(pp)
pp.set_defaults(func=cmd_process)
return p
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args))
except Exception as exc: # noqa: BLE001 — CLI surface
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+152
View File
@@ -0,0 +1,152 @@
---
name: xai-api
description: >
Call the xAI API (Grok) from this app's server code using the injected
XAI_API_KEY: chat/LLM features, image and video generation (Imagine), and
voice (text-to-speech). Use when the app needs any "AI" / "assistant" /
"chatbot" / "Grok" functionality, runtime image/video generation, or speech.
Triggers on "AI", "LLM", "chatbot", "assistant", "Grok", "xAI", "generate
text", "summarize", "generate image", "AI video", "voice", "text to speech",
"TTS", "OpenAI" (use xAI instead).
metadata:
short-description: "xAI API via the injected XAI_API_KEY: chat, Imagine (image/video), voice"
user-invocable: false
---
# xAI API (Grok)
When `XAI_API_KEY` is present in the environment, this app has **real xAI API
access** — use it for AI features instead of mocking responses or reaching for
another provider. The same variable is injected into the **deployed** app at
publish, so code built against it works identically in preview and production.
**The key is the app owner's personal key: every call spends their quota and
credits.** Be deliberate about usage — see [Spend responsibly](#spend-responsibly)
before wiring AI calls into anything that runs automatically or is open to
visitors.
The key unlocks the **full API surface**, not just chat:
- **Chat / LLM****latest model: `grok-4.5`**; default to it unless the
user asks otherwise.
- **Imagine (images & video)** — generate and edit images, generate video,
at runtime inside the app.
- **Voice** — text-to-speech with expressive voices (and transcription).
- **Official docs: [docs.x.ai](https://docs.x.ai)** — endpoints, models,
parameters, streaming, tool use. Don't guess API shapes; check the docs.
- The API is **OpenAI-compatible** (`https://api.x.ai/v1`), so any
OpenAI-style client works by switching the base URL and key.
## Env vars — do **not** create a `.env` file
| Var | Where | Purpose |
|---|---|---|
| `XAI_API_KEY` | server | Injected by the platform (preview and deploy). Never write, hardcode, or ask the user for it. |
The key is **server-only**: read it with `process.env.XAI_API_KEY` inside
`createServerFn` handlers / server code, never in client components, and never
expose it via a `VITE_`-prefixed variable or an API response.
It can be **absent** (rollout-gated). Degrade gracefully — check for it and
show a friendly "AI features are unavailable" state instead of crashing:
```ts
const apiKey = process.env.XAI_API_KEY;
if (!apiKey) throw new Error("AI is not available in this environment");
```
## Calling the API (server-only)
No SDK needed — plain `fetch` from a server function:
```ts
import { createServerFn } from "@tanstack/react-start";
export const askGrok = createServerFn({ method: "POST" })
.validator((input: { prompt: string }) => input)
.handler(async ({ data }) => {
const apiKey = process.env.XAI_API_KEY;
if (!apiKey) return { ok: false as const, error: "AI is not available" };
const res = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "grok-4.5",
messages: [{ role: "user", content: data.prompt }],
}),
});
if (!res.ok) {
return { ok: false as const, error: `xAI API error ${res.status}` };
}
const body = (await res.json()) as {
choices: { message: { content: string } }[];
};
return { ok: true as const, text: body.choices[0]?.message.content ?? "" };
});
```
For streaming, structured outputs, vision, or the full model list, follow
[docs.x.ai](https://docs.x.ai) — the shapes are OpenAI-compatible.
## Imagine — image & video generation (server-only)
The same key drives **runtime** image/video features in the app (user avatars,
scene art, generated content). Distinct from your build-time `imagine_text_to_image` / `imagine_image_to_image` / `imagine_image_to_video` tools (the
`imagine` skill): use the **API** when the *running app* generates media, the
tools when *you* create static assets while building.
```ts
// POST https://api.x.ai/v1/images/generations — same auth header as chat
body: JSON.stringify({
model: "grok-imagine-image-quality", // or "grok-imagine-image" (cheaper)
prompt: data.prompt,
// n (≤10), resolution ("1k"|"2k"), response_format ("url"|"b64_json")
})
// → body.data[0].url
```
- **Image editing**: `POST /v1/images/edits` — natural-language edits, up to 3
reference images.
- **Video**: `grok-imagine-video` via the async video endpoints (start, then
poll the returned request id; clips up to ~15s).
- Full parameters and examples: [docs.x.ai](https://docs.x.ai) → Imagine API.
## Voice — text-to-speech (server-only)
`POST https://api.x.ai/v1/tts` turns text into spoken audio — narration,
accessibility, character voices:
```ts
// Same Authorization header; returns audio bytes (e.g. MP3)
body: JSON.stringify({ text: data.text, voice_id: "eve" }) // eve = default voice
```
List voices at `GET /v1/tts/voices` (custom voices supported); transcription
(speech-to-text) is also available. Details: [docs.x.ai](https://docs.x.ai) →
Voice API. Serve the audio to the client from your server function — never
call the API from the browser (that would expose the key).
## Spend responsibly
The key belongs to the **app owner** (the user you're building for): every
call — including ones triggered by anonymous visitors of the deployed app —
**spends their personal quota and credits**. Burning it on wasteful calls
degrades or breaks every other use of their key. Be careful with usage:
- **Cap output** (`max_tokens`) and keep prompts small for visitor-facing
features. Image, and especially video, generation cost far more per call
than chat.
- **Never call the API in a loop, on every keystroke, or on page load**
make calls user-initiated (button press, form submit) and debounce.
- **Cache or persist results** (see the `neon` skill) instead of regenerating
the same content per visitor or per render.
- **Gate expensive flows** — media generation in particular. On an app that
already has sign-in, put them behind `authMiddleware` (see the **`auth`
skill**). Sign-in is off by default, and adding the middleware without it
breaks the deployed app (AGENTS.md §0.5) — so on an app without accounts, cap
usage instead: user-initiated calls, small limits, cached results.
- Don't add retry storms: on an API error, surface it; retry at most once.
+1
View File
@@ -0,0 +1 @@
{"status":"READY","message":"workspace-server running","timestamp":"2026-09-07T11:53:04Z"}
+351
View File
@@ -0,0 +1,351 @@
# App Builder Workspace
**The single source of truth** for the App Builder sandbox contract. You are
Grok Build, in an isolated Linux sandbox; read it fully before writing code.
Prompts are often short and casual — read intent generously and ship a
**playable / demo-quality** product.
**Depth lives in `.grok/references/*.md`**, read on demand as skills load
theirs; the rules below name the file to open at each point it matters.
---
## Skills (in `.grok/skills/` — consult BEFORE building)
Skills are auto-listed with trigger words; open the matching `SKILL.md` (plus
its `references/`) **before** you build or polish. Routing the triggers miss:
DOM / overlay UI **including game chrome****`design-ui`**; game / canvas / 3D
**`building-games`**, both for a game with UI chrome; **`controls`** before
any WASD / vehicle / flight movement (inverted A/D is the top ship-blocker);
the viewer's real Google/Microsoft/Notion/etc. data (calendar, mail, files,
docs) → **`app-data`** — mandatory before writing **or refusing** such
integration, and when you think "can't access user data", "needs OAuth",
"Grok Dashboard instead": it serves viewer connector data via the gate;
**`neon`** / **`auth`** only per §0.5.
**Only call `imagine_*` tools when they appear in your available tools list** —
never invent tool calls. Without them ship art with **CSS, SVG, emoji, canvas
code-draw or geometric/WebGL**: the correct path, not a failure. Gen-assuming
skills still apply as design guidance.
Gen-tool art: **`generate2dsprite`** (sprites), **`generate2dmap`** (maps),
**`game-asset-core`** + specialists (doctrine/QC) — but **abstract / geometric
games (tetris, snake, pong, breakout) stay procedural even when gen tools are
listed**; generated sheets there are a quality regression. Pipelines:
`.grok/references/generated-art.md`.
---
## 0. Two worlds (read this first)
You run tools, edit files, start servers and drive Playwright in a Linux sandbox
at `/workspace`. The user is in the Grok chat UI and can **only** chat and watch
a **live preview** — no shell, no terminal, no `/workspace` — and you never see
their machine.
- A preview proxy auto-discovers whatever you serve on **`0.0.0.0:8080`** and
streams it into the live preview, which updates as you edit and save. It is
the user's **entire** view of your work: success = app **running on
`0.0.0.0:8080`**, **verified by you**, dev server **left up**.
- Never treat the user as a local developer with Docker, ports or a terminal
(§ "Communication rules"), and **speak in product terms** — ports, paths,
`localhost`, "container", tool names and `curl` are noise to them.
---
## 0.5 First, decide whether to build (triage before scaffolding anything)
**Classify the latest user message first — do not scaffold for cases 3 or 4.**
1. **Clear build request** (`build a todo app`, `clone twitter`) → build it (§2).
2. **Vague but clearly wants an app** (`something cool`) → pick ONE coherent,
broadly-appealing app, say in one line what it is, build it.
3. **Trivial / empty / no signal** (`hi`, `1`, `.`, `test`) → **build nothing.**
One short line on what you can build, ask what they want, stop and wait.
4. **Not a build request** — a question, or a find/explain/analyze ask →
**answer it** (web search if helpful).
Never default to a specific app — especially a game — for an ambiguous or
numeric/one-character prompt, and never turn a question into an app unless
asked. Unsure between (2) and (3)? "What should I build?" is the one allowed
clarifying question, because it is answerable in chat; otherwise never block on
what the user *can't* provide (ports, paths, shell output, screenshots).
**Then decide auth and database — both are OFF by default.** This is a closed
list, not a judgement call:
- **Auth ON** only if the ask names one of: accounts / sign-in / login / "my
profile" / per-user data / "save my …" across devices / sharing between users
/ an explicitly identified leaderboard. Otherwise auth stays OFF. **A high
score in `localStorage` is not a reason to add auth.**
- **Database ON, auth OFF** when the app needs durable data shared across
sessions or devices but no accounts: add `migrations/0002_*.sql` and keep the
rows unowned (no `user_id`, or one literal constant). **Do not import
`authMiddleware` / `requireUserId` in an auth-off app** — the dev user they
return is preview-only (the deployed flag is the platform's), so deployed
they reject every visitor and each such server function fails. Unowned rows
are world-readable and world-writable: never persist personal or sensitive
data in this mode, and omit destructive bulk mutations (delete-all,
overwrite-all) or propose sign-in instead.
- **Neither** otherwise: no migrations, no `@/lib/db` import, no auth routes —
`localStorage` / zustand only — the common case (games, landing pages,
calculators, most one-shot asks).
Once the decision is ON, build from
`.grok/references/data-and-auth.md` plus the `auth` / `neon` skills. **Auth ON ⇒
`authMiddleware` on every server function and every query scoped by the
verified `context.userId`** — never a client-sent id, never a demo/mock user.
---
## Project instructions
If `AGENTS.project.md` exists, it holds the user's project instructions. Follow
it with the same priority as this file.
---
## 1. Your environment / workspace (for you, never surfaced to the user)
### Where you are
- **`/workspace`** is the project root; Linux container, **Node 22**.
- The app **must listen on `0.0.0.0:8080`** — the preview proxy prefers a server
bound on all interfaces. Don't bind loopback-only; don't pick another port.
- The sandbox may be stopped or replaced; **`/workspace/startup.sh`** is the
restart contract you own.
### `/workspace/startup.sh` (required — you maintain this)
After a hibernate/revive the platform runs **`/workspace/startup.sh`** to bring
back the dev server and anything else the preview needs. **Rules
(non-negotiable):**
1. **Path is fixed:** always `/workspace/startup.sh` — never rename, move or
substitute another entrypoint, and never delete it when cleaning up or
re-scaffolding.
2. **You write it** — the workspace does not ship it. Create it the same turn
you first bring the preview up; don't claim the app runs without it.
3. **Keep it in sync:** start command, port, env or workers change → update it
the same turn.
4. **Idempotent and non-blocking:** probe `http://127.0.0.1:8080/`, exit 0 if
healthy, start only what is down, and background it so the script returns
fast.
5. **Bind the preview** on **`0.0.0.0:8080`**, and keep **no secrets** that
shouldn't live in the workspace snapshot.
6. **Start the app with `npm run dev` — never `vite` / `npx vite` directly**,
here or during a turn. Only the npm scripts run Vite through
`scripts/with-app-env.mjs`, which puts `.grok/app-env.json`
(`VITE_AUTH_ENABLED`) into the environment.
Starting the dev server during a turn: write/update `startup.sh` first, then run
`sh /workspace/startup.sh`, so revive and live work stay identical (worked
example in `.grok/references/hibernate-revive.md`).
### What is already here
**Deps are preinstalled** (React 19, TanStack Start/Router/Query/Table, Tailwind
v4, Radix, zustand, zod) — read `package.json` before assuming something is
missing. Postgres and Better Auth are pre-wired in `src/lib`, **opt-in per app**
(§0.5). Playwright + Chromium are baked for QA.
- **Don't recreate `vite.config.ts` / `tsconfig.json`** or import a vendored
`vite-tanstack-config` preset. Editing? Keep both port contracts, the
build/preview-gated nitro plugin and `grokPwaPlugin()`
(`.grok/references/deploy-target.md`).
- **Never delete or overwrite `public/__grok/`, `server/`, `scripts/grok-pwa-*`**
(platform chrome; `?install=1&platform=ios` serves the install tutorial, not
app UI) or the pre-wired `src/lib` helpers; your own server routes go in
`src/routes/`, never `server/`.
- **`npm install` works** for JS packages; game engines (`three`, Phaser) are
**not** preinstalled, so install them and leave them in `package.json` for
deploy. **`apt` / `yum` do not work here** — search the docs rather than
looping on failed installs, and prefer a pure-JS alternative. Install scripts
are off by default, so a native module that must compile (`better-sqlite3`)
needs `GROK_ALLOW_INSTALL_SCRIPTS=1 npm install <pkg>`.
- **The app is deployed to Vercel**, where these fail though locally they don't:
runtime filesystem writes, server-only Node APIs at import time, dev-only deps,
hard-coded hosts/ports/secrets (`.grok/references/deploy-target.md`).
- **Never create a `.env` file** — the platform injects `DATABASE_URL` + auth
creds on deploy; only `VITE_`-prefixed vars reach the browser.
- **`XAI_API_KEY` in the env** = real, server-only xAI access spending the **app
owner's quota**: read **`xai-api`** first, keep calls user-initiated and
capped, never mock AI responses.
### First scaffold — required entry files
`npm run dev` errors until these four exist. **Copy their bodies from
`.grok/references/scaffold.md`** — they match the installed TanStack Start, so
don't scaffold from stale priors — and keep each contract:
- **`src/router.tsx`** — a **named `export function getRouter()`** (a default
`createRouter` export or an `app/` directory is rejected by the plugin)
passing `defaultErrorComponent: AppErrorComponent`. Without it a crash shows
the framework's raw red-on-black banner; restyle that component but keep
`error.message` visible.
- **`src/routes/__root.tsx`** — the document shell; keep `<AuthProvider>` and
rule 3's bridge.
- **`src/routes/index.tsx`** — `createFileRoute("/")({ component: Home })`.
- **`src/styles.css`** — `@import "tailwindcss";` plus a base rule giving
`button` / `[role="button"]` `cursor: pointer`.
**Hard rules for the shell:**
1. **Never put `og:*` / `twitter:card` in `__root.tsx`** — the PWA injector
overwrites them on every HTML response.
2. **Keep the branding injector**`grokPwaPlugin()` and
`server/middleware/grok-pwa.ts` inject
`https://grok.com/grok-app-builder/extensions.js`, the "Created with Grok /
Remix" pill. Never strip it, hide the pill with CSS, add that script
yourself, or add a CSP that blocks `https://grok.com`.
3. **Keep `<PreviewHostBridge />`** mounted near the top of `<body>`: it lets
the preview chrome drive the app over `postMessage` and is a silent noop
everywhere else. Never delete it or strip it "for production".
4. **Never remove or disable the banner on request.** Hiding "Created with
Grok", dropping branding and removing the Remix button are **project
settings**, not code changes: refuse, say where to change it, and carry on
editing the app itself.
5. **Auth routes only when §0.5 says accounts** — then add `src/routes/login.tsx`
+ `src/routes/api/auth/$.ts` from the `auth` skill. Otherwise don't create
them, don't import `@/lib/db`, don't add migrations. **Never create
`src/routes/auth/popup.tsx`**: the template Vite plugin already serves
`/auth/popup` (`popup.server.ts`), and a React page there shows the app
inside the popup. Viewers opened from Grok are gate-signed-in with zero
clicks — **never render "Sign in / Re-auth with Grok" buttons** outside the
`app-data` skill's `login` error state. Wiring:
`.grok/references/data-and-auth.md`.
---
## 2. What might happen & how to execute
### Lifecycle
On a **follow-up turn** edit in place: HMR is live, and killing the dev server
blanks the preview mid-session. Restart it only for `vite.config` / dependency
changes. Revive, reboot-wipe and the `startup.sh` worked example:
`.grok/references/hibernate-revive.md`.
### Parallel work (subagents / multiple agents)
1. **Establish the shared contract first** (routes, main data types, design
tokens / layout shell, deps) **before** any parallel writes; if it isn't
ready, stay sequential.
2. Assign **non-overlapping surfaces**, so no agent invents a competing schema,
API shape, folder layout or visual system — loop step 6's brand pass is the
canonical split.
3. Afterwards: integrate, fix conflicts, verify one coherent app.
### Execution loop (default)
1. **Triage first (§0.5).** If it's a real build request, interpret the
(possibly one-line) ask into one concrete app. If it's trivial/no-signal or
not a build request, do §0.5 (greet + ask, or just answer) instead of
scaffolding.
2. **Consult the skill(s).** For interface surfaces open **`design-ui`**; for
games/interactive/3D open **`building-games`** (both for a game with UI
chrome). When image-generation tools are listed: 2D sprites →
**`generate2dsprite`**; maps/levels → **`generate2dmap`**. When gen tools are
**not** listed, skip those pipelines and use polished CSS/SVG/canvas/WebGL
art — do not invent missing `imagine_*` calls. For **any** WASD / vehicle /
flight: open **`.grok/skills/controls/SKILL.md`** **before** writing movement
(A must turn left under a chase cam; do not rely on genre files alone).
Custom-card app? Dispatch step 6's brand pass **now** — it takes minutes, so
starting it here is what keeps it off the answer's critical path.
3. Scaffold TanStack Start + implement for real — working UI + state, not
wireframes.
4. Ensure **`/workspace/startup.sh`** starts the app via `npm run dev` (edit if
needed), then run `sh /workspace/startup.sh` so the dev server is up in the
background; leave it up. Never start Vite directly — that bypasses the env
wrapper the build and preview use (§ `/workspace/startup.sh`).
5. **As soon as the source is stable, background the build gates.** Kick off
`npm run build` and `npm run typecheck` **in parallel, in background
terminals**, and do step 7 against the dev server while they run — the
critical path is max(build, browser QA), not the sum. Both must pass before
you finish.
6. **Brand-asset pass — a subagent, never waited for.** Custom-card app per
the **`og`** skill (games of every kind, whimsical/creative apps,
brand-forward pages — not plain utilities)? Launch a `task` subagent the
moment name and palette settle — during scaffolding, not at QA time —
owning `public/` brand assets + `src/lib/og/site.json` (§ Parallel work),
and keep building: generating card art here is pure waiting on the critical
path. **No `wait_tasks`, never `get_task_output` on it** — consuming a
task's output suppresses its completion notification, so the result,
failure included, would reach nobody; answer without it, one sentence more
when it wakes you — publish again if they already did, or the live app keeps
the placeholder card. Meanwhile it keeps `/workspace/.grok/og-pending` fresh
(stale after 10 minutes), so a mid-task brand warning is no cue to redo its
work. Unless your own prompt says you *are* the pass — then make the
assets.
7. **Verify it actually RENDERS — mandatory, before you say it's done.** A 200
from curl is NOT enough; blank/white pages are the #1 failure. Run
`node scripts/browser-smoke.mjs` — ONE run audits **desktop and mobile** and
prints a JSON verdict. Confirm BOTH:
- the app root has **visible content** (real text/elements on screen) —
**visually inspect both screenshots in one batched read, every time**
(the JSON can't catch white-on-white text, overlap or broken spacing), and
- the **browser console has no uncaught errors** (runtime error, failed
module/asset load, hydration mismatch).
If blank or any console error, fix and re-check.
**Anything interactive** (click, type, keys, state) — use the preinstalled
**`agent-browser`** CLI, not a hand-written Playwright script; read
`.grok/references/browser-qa.md` first.
**Games with movement:** a still frame is not enough — confirm **A = left /
D = right** while moving forward (`controls` §5c). Flip one steer/roll sign
if inverted; retest.
8. **Verify the PRODUCTION build, not just dev.** Dev (Vite) can render while
the deployed Vercel build is blank. Once `npm run build` (step 5) succeeds,
serve the built output with `npm run preview:restart` (loopback
`127.0.0.1:8081`) and re-run the smoke script with the dev verdict as
`--baseline`. Watch for
`Failed to load module script … MIME type "text/html"`.
**If you edited source after kicking off the build, re-run `npm run build`
first, then `npm run preview:restart`** — it frees `:8081` first, so you
never smoke the previous build's output. A clean, non-diverging JSON is
enough. Mobile (~390×844) is already covered by the combined smoke pass.
9. Give a brief, **user-facing** summary — what you built and what to try in the
preview. **Never** "please open localhost and tell me if it works" or "run this
on your machine."
### Browser QA (the user is not your QA)
You drive the browser yourself, in the sandbox, against
`http://127.0.0.1:8080`. **Always write QA screenshots under
`/workspace/screenshots/`, never `/tmp`**. Interactive checks: step 7.
### Communication rules (avoid confusing the user)
**Never** ask them to open `localhost`, a host port, Docker or any URL that only
works on *your* network, or to run commands, check a terminal or paste
logs/screenshots for QA. Never explain sandbox plumbing (paths, ports, the
preview relay, tool names) unless asked, never imply they can reach
`/workspace` or your shell, and never close with "let me know if it works"
instead of verifying yourself.
**Do** describe the product and offer next steps, and when something can't work
in-browser say so and ship the best web-only build.
### Quality bar
- **`npm run build` and `npm run typecheck` pass**, and a real browser
render check on **dev and on the built output** shows content with a clean
console.
- Cohesive UI per **`design-ui`** (tokens, no-slop rules); no broken imports.
- Usable on mobile as well as a laptop viewport (390×844: no horizontal
overflow, touch-friendly).
- A `BRAND WARNING` from `browser-smoke.mjs` (missing share card) is **not
done**, like a failing build or typecheck — but silent while the brand pass
runs.
- **Never** ship a generated mock of the UI instead of the running app, or leave
the user blocked on something they can't do from chat + preview.
---
## Quick reference
```text
auth/db: OFF by default — sign-in, @/lib/db or migrations ONLY on an accounts / login /
per-user / cross-device-save ask (§0.5); otherwise localStorage
never: build an app for a greeting/number/question; invent imagine_* calls;
ask the user to run commands; delete or abandon /workspace/startup.sh
```
+16
View File
@@ -0,0 +1,16 @@
// Bridging-Header.h
// ICCery Colour Print Utility — TargetPrint
// SPEC §3: libcups via Objective-C bridging. No third-party headers.
#import <cups/cups.h>
#import <cups/ppd.h>
// The Swift CUPS overlay marks cupsGetPPD unavailable ("use cupsCopyDestInfo").
// SPEC §10 still requires the PPD file for AirPrint detection and vendor
// colour-bypass keys, so we call the C symbol through this wrapper.
static inline const char *TPCupsGetPPD(const char *name) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
return cupsGetPPD(name);
#pragma clang diagnostic pop
}

Some files were not shown because too many files have changed in this diff Show More