# Adding a game to Garden Street Games

Games are built in **separate projects** that live beside this one under
`~/Documents/Projects/`. This repo only hosts the finished export. A "game" here
is just a folder of static files that gets an `<iframe>` pointed at it.

`pioneering` is the worked example — when in doubt, copy exactly what
`games/pioneering/` does.

---

## The loop, end to end

    ~/Documents/Projects/<GameName>/     you build the game here (Godot 4)
                 │
                 │  1. export to web
                 ▼
    games/<slug>/                        the export lands here
                 │
                 │  2. gzip the wasm, add the loader shim
                 │  3. register in js/games-data.js
                 │  4. draw a 16:9 cover
                 │  5. verify locally
                 ▼
    ~/Documents/Projects/gsg-deploy/     the bundle to upload
                 │
                 │  6. drag into the Cloudflare dashboard, Deploy
                 ▼
    https://gardenstreetgames.com/

Steps 1–5 are mechanical and documented below. Step 6 is a human dragging a
folder — see `docs/DEPLOY.md` for why it cannot be automated here.

---

## 1. Export the build

Set up a **Web** export preset in the game's own project, with an export path of
`build/web/index.html`. The filename must be `index.html` — the site always
requests `games/<slug>/index.html` and never special-cases names.

Then export from the command line:

```
~/Downloads/Godot.app/Contents/MacOS/Godot \
  --headless --path . --export-release "Web" build/web/index.html
```

That produces `index.html`, `index.js`, `index.wasm`, `index.pck`, two audio
worklets and some icons. **All of them are needed** — `index.html` alone is a
black screen.

### The four export settings that actually matter

Every one of these fails in a way that does not point at itself.

- **`renderer/rendering_method.web` must be `gl_compatibility`.** The Web
  platform cannot run Forward+. Setting only `renderer/rendering_method.mobile`
  is NOT enough — web reads its own `.web` override and otherwise falls back to
  the base method, which defaults to `forward_plus`. In `project.godot`:

  ```
  [rendering]
  renderer/rendering_method.web="gl_compatibility"
  ```

  Expect Compatibility to silently drop **SSAO** and soft shadow penumbra. It
  does not warn. Surfaces facing away from the sun crush much darker than they
  do in the editor, so do a lighting pass against the *web* build, not the
  editor — in Pioneering this turned the alley-side privacy fence solid black.

- **`variant/thread_support=false`.** A threaded build needs `SharedArrayBuffer`
  and therefore COOP/COEP headers on every response. Single-threaded runs on any
  static host with zero configuration. Also: cross-origin isolation blocks ad
  tags and rewarded-video SDKs, so if this site ever runs ads, single-threaded
  is a hard rule rather than a convenience.

- **VRAM texture compression off unless the project enables the formats.**
  `vram_texture_compression/for_mobile=true` requires
  `rendering/textures/vram_compression/import_etc2_astc=true` in project
  settings. Without it the export dies with `Cannot export project with preset
  "Web" due to configuration errors:` and **no error text after the colon**. If
  the game has no textures, set both `for_desktop` and `for_mobile` to `false`.

- **Canvas Resize Policy: Adaptive** (`html/canvas_resize_policy=2`, the
  default) so the game fills the iframe instead of a fixed pixel box. Leave
  `html/focus_canvas_on_start=true` on, or keyboard input never reaches the game
  inside the iframe.

Turn the PWA off (`progressive_web_app/enabled=false`). A service worker on an
iframed game buys nothing and will serve you stale builds while you iterate.

Export templates must be installed at
`~/Library/Application Support/Godot/export_templates/<version>/`. They do not
come with the editor: Editor → Manage Export Templates → Download and Install.

---

## 2. Place the folder, gzip the wasm, add the shim

Pick a kebab-case slug and copy the export in:

```
cp build/web/* ~/Documents/Projects/GardenStreetGames/games/<slug>/
```

Don't nest it deeper — `games/<slug>/index.html`, not `games/<slug>/build/…`.

Then deal with the wasm, which is the one genuinely awkward part:

```
cd games/<slug>/
gzip -9 -c index.wasm > index.wasm.gz
rm index.wasm
```

A Godot 4.7 release export is a **~38 MB `.wasm`**, and Cloudflare refuses any
single asset over **25 MiB**. Gzipped it is about 9.6 MB, which fits.

To make the gzipped file loadable, copy the shim block from the top of
`games/pioneering/index.html` — it sits just above `<script src="index.js">` —
into the same place in the new game's `index.html`, changing nothing. It patches
`window.fetch` so a request for `index.wasm` is rewritten to `index.wasm.gz`,
piped through `DecompressionStream('gzip')`, and returned as a normal
`application/wasm` response Godot can still stream-compile.

**Do not instead try serving the `.gz` under the real name with a
`Content-Encoding` header in `_headers`.** Cloudflare strips that header and
re-compresses on top; the browser is left holding gzip and the game hangs
forever on load with nothing in the console. `docs/DEPLOY.md` has the evidence.

If a future export ever comes in under 25 MiB, drop the `.gz` and the shim and
ship `index.wasm` plainly.

---

## 3. Register it in `js/games-data.js`

The `GAMES` array is the single source of truth for the whole site. Replace one
of the bare `{ status: 'coming-soon' }` slots with a full object:

```js
{
  slug: 'your-slug',
  title: 'Display Title',
  tagline: 'One punchy line, under 60 chars',
  description: 'A paragraph for the detail page.',
  status: 'live',
  year: 2026,
  tags: ['arcade', 'lake-city'],
  engine: 'Godot',
  embed: { path: 'games/your-slug/index.html', width: 1600, height: 900 },
  cover: 'assets/img/covers/your-slug.svg',
  accent: '#9BFF3C'
}
```

- `embed.width` / `embed.height` set the aspect the player locks to. Match the
  game's `window/size/viewport_*` — 1600×900 for Pioneering.
- The slug must exactly match the folder name.
- **A slot with no `slug` is an empty slot.** That single fact drives every
  renderer: no slug means a blank black-and-white "Coming soon" tile that links
  nowhere and has no detail page. Never give a slot a title, tagline or cover
  before the game is real — see the hard constraint in `CLAUDE.md`.

---

## 4. Cover art

Add a 16:9 SVG at `assets/img/covers/<slug>.svg`, `viewBox="0 0 1600 900"`.

Bold geometric shapes on `--ink`, using **only** values from `css/tokens.css`.
These files are loaded through `<img>` so they cannot read CSS variables from the
page — the hexes are written literally, but they must still be exactly the token
values. `assets/img/covers/pioneering.svg` is the reference: receding fence
lines, one green arc, one amber marker, and a small corner label.

---

## 5. Verify locally, before it goes anywhere

```
ruby -run -e httpd . -p 8000
```

1. `http://localhost:8000/games/<slug>/` — the export runs standalone.
2. `http://localhost:8000/pages/game.html?slug=<slug>` — it runs embedded,
   fills the frame, and takes keyboard input after the click-to-start overlay.
3. `http://localhost:8000/` — the carousel shows it, and the empty slots are
   still blank.

Hard-reload (`cmd+shift+R`) after touching `js/games-data.js`. The browser caches
it aggressively and you will otherwise debug a stale registry — this has cost
real time more than once.

---

## 6. Ship it

Build the deploy bundle and hand it over. Full detail in `docs/DEPLOY.md`; the
short version is that everything gets copied to `~/Documents/Projects/gsg-deploy`
and a human drags that folder into the Cloudflare dashboard.

Check `find ~/Documents/Projects/gsg-deploy -type f -size +25M` comes back empty
before uploading. If it doesn't, the deploy will be rejected.

---

## Updating a game that is already on the site

This is the common case once a game ships: you changed something in the game
project and want the site to carry the new build.

**It is not a file copy, and the obvious shortcut breaks the game silently.**

Two traps, both hit for real:

- `cp build/web/* games/<slug>/` overwrites `index.html`, and `index.html` is
  where the gzip loader shim lives. The shim disappears, the page asks for an
  `index.wasm` that is not there, and the game hangs on a black canvas with
  nothing in the console.
- Keeping the *old* `index.html` instead, to preserve the shim, leaves stale
  `fileSizes` pointing at the previous `.pck`. Less serious — it only skews the
  loading bar — but it means the page and the build disagree.

The fix for both: take the **fresh** `index.html` and re-inject the shim into
it. Also note the export directory can contain `.import` files, so copy named
files rather than `*`.

### The procedure

```sh
GAME=pioneering
PROJ=~/Documents/Projects/Pioneering
SITE=~/Documents/Projects/GardenStreetGames
GODOT=~/Downloads/Godot.app/Contents/MacOS/Godot

# 1. export
rm -rf "$PROJ/build/web" && mkdir -p "$PROJ/build/web"
"$GODOT" --headless --path "$PROJ" --export-release "Web" "$PROJ/build/web/index.html"

# 2. lift the shim out of the live page and splice it into the fresh one.
#    Reads the old file first, writes to a temp, then moves — so an error
#    part-way through cannot leave a half-written index.html behind.
perl -0777 -e '
  my ($old, $new, $out) = @ARGV;
  my $mark = "\t\t<!-- ---";
  my $tail = "\t\t<script src=\"index.js\"></script>";
  local $/;
  open my $o, "<:raw", $old or die "no current index.html\n"; my $O = <$o>; close $o;
  my $a = index($O, $mark);     die "no shim found in current index.html\n" if $a < 0;
  my $b = index($O, $tail, $a); die "shim end not found\n"                  if $b < 0;
  my $shim = substr($O, $a, $b - $a);
  open my $n, "<:raw", $new or die "no fresh export\n"; my $N = <$n>; close $n;
  my $i = index($N, $tail);     die "loader tag not in fresh export\n"      if $i < 0;
  substr($N, $i, 0) = $shim;
  open my $w, ">:raw", $out or die; print $w $N; close $w;
  print "shim re-injected (", length($shim), " bytes)\n";
' "$SITE/games/$GAME/index.html" "$PROJ/build/web/index.html" "$SITE/games/$GAME/index.html.new"
mv "$SITE/games/$GAME/index.html.new" "$SITE/games/$GAME/index.html"

# 3. everything else fresh — named files, never `*`
cp "$PROJ"/build/web/index.js "$PROJ"/build/web/index.pck "$SITE/games/$GAME/"
cp "$PROJ"/build/web/*.worklet.js "$SITE/games/$GAME/"

# 4. the wasm, gzipped. The raw one must never land in the site folder.
gzip -9 -c "$PROJ/build/web/index.wasm" > "$SITE/games/$GAME/index.wasm.gz"
```

If step 2 dies with "no shim found", the site copy of `index.html` has already
been clobbered by a stray `cp`. Recover the shim block from
`games/pioneering/index.html`, or from any earlier deploy bundle, and paste it
back above `<script src="index.js"></script>`.

### Check before shipping

```sh
cd "$SITE/games/$GAME"
grep -c DecompressionStream index.html          # 4 — the shim survived
grep -o '"fileSizes":{[^}]*}' index.html        # pck size must match the file
stat -f%z index.pck
ls | grep -c '\.import$'                        # 0
ls | grep -c '^index\.wasm$'                    # 0 — only index.wasm.gz ships
find . -type f -size +25M                       # empty
```

Then play it locally end to end (`games/README.md` step 5) before bundling.
A build that loads is not the same as a build that runs — the renderer
differences in the Compatibility notes above only show up once it is moving.

---

## Pushing scores to the leaderboard

Optional, but if the game has a score this is all it takes. Copy
`scripts/autoload/WebScore.gd` from the Pioneering project, register it as an
autoload after your event bus, and point it at whatever signal means "the run
is over".

The game posts one message out of its iframe when a run ends. The site listens
in `pages/game.html` and writes it to the board.

**The contract** — keep this in sync with `js/leaderboard.js`:

```
{ source: "gsg-game", version: 1, type: "run_finished",
  score: <int>, meta: { ...anything... } }
```

Posted with `window.parent.postMessage(msg, window.location.origin)`, from
inside the export, via `JavaScriptBridge.eval`. Outside a web build the whole
thing is inert, so it costs nothing in the editor.

**Two things the game deliberately does not send:**

- **No slug.** The page already knows which game it embedded, from its own
  `?slug=`. A slug arriving from inside the iframe would be ignored at best,
  and at worst a way for one game to write into another game's board.
- **No player name.** Names are entered on the page after the run, never in the
  game. That keeps name handling in one place and means a run is never lost
  because a player had not set a name yet.

**What the page checks** before believing anything:

- the message came from the exact iframe that page created (`event.source`)
- the origin matches the site's own
- `source` is `"gsg-game"`, `type` is `"run_finished"`
- the score is a finite, non-negative number

Anything else is dropped silently.

**Scores are not trustworthy and the site does not pretend otherwise.** They
come from the client, so anyone with a console can post whatever they like.
There is no server to validate against. That is fine for a small studio site;
it would not be fine for prizes.

**Storage is per-browser today.** `js/leaderboard.js` keeps scores in
`localStorage` behind an adapter, and every surface that shows a board says so
in plain words rather than implying a global ranking. Making it genuinely
shared means writing one more adapter with the same four methods — there is a
worked sketch in the comments of that file — and pointing `ACTIVE` at it. No
other file changes.

---

## Godot gotchas that will bite you

- **Must be served over HTTP, never `file://`.** Godot loads its `.wasm`/`.pck`
  via `fetch()`, which browsers block on `file://`. A black canvas with console
  errors about fetch or CORS almost always means the file was opened directly.
- **Relative paths only.** Don't hardcode a domain or absolute path in the
  export; Godot's default relative references are what make the folder portable.
- **Keyboard focus inside an iframe.** Input only reaches the game once the
  iframe and its canvas have focus. The player page's click-to-start overlay
  handles the first click, and `focus_canvas_on_start` handles the rest.
- **Cloudflare 307s `index.html` to the directory.** Requesting
  `games/<slug>/index.html` returns a 307 to `games/<slug>/`. Harmless — the
  iframe follows it — but don't mistake it for a broken path.
