evolutionary-arcade 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Games ship media for phones. arcade.json has `orientation` (`"landscape"`, `"portrait"`, or `"both"`; leaving it out means landscape) and `media_vertical`, a 9:16 set with the same fields as the landscape one (`thumbnail`, `screenshots`, `demo_video`, `preview_video`, `preview_gif`). The top-level media fields are the landscape set.
6
+ - A portrait or both game with touch must ship its own vertical media from real upright play: a thumbnail, at least one screenshot, and a vertical demo when it has a landscape demo.
7
+ - New `arcade media vertical`. For a landscape game it centers the landscape thumbnail, screenshots, and demo at full width over a blurred, darkened copy of themselves at 1080x1920, cuts a 480x854 hover preview and a 360x640 GIF, writes them to `media/vertical/`, and sets `media_vertical`. For a portrait or both game it cuts the vertical preview and GIF from the vertical demo. `arcade publish` makes a landscape game's set when it's missing, the way it makes the GIF.
8
+ - The dry run warns when game code calls `requestFullscreen` (or the webkit, moz, and ms versions): "the arcade's player owns fullscreen; remove your fullscreen button". It also flags a missing vertical thumbnail and a vertical set older than the landscape media.
9
+ - `arcade fork`, `blend`, `pull`, and `regen` carry `orientation` and `media_vertical` over.
10
+ - Skills: `arcade-publishing` covers deciding a game's orientation and capturing both sets in one pass (1280x720, plus 390x844 with touch emulation). `arcade-building-games` says "Don't build fullscreen. The platform owns it." `arcade-onboarding` and `arcade-fork` include the vertical set in their capture steps and time estimates.
11
+
3
12
  ## 0.3.0
4
13
 
5
14
  - Every publish ends with a **Share it** block: the game's link, a video-description line, an X post draft under 280 characters, an X intent link, and the GIF to attach. A fork or blend's draft credits each parent's creator, by X handle when their profile links one. The agent drafts; the creator posts. The CLI never posts anywhere.
package/README.md CHANGED
@@ -56,6 +56,7 @@ Everything you publish is public, including the source, and every game is open s
56
56
  | `arcade publish [dir] [--dry-run] [--yes]` | Check everything, list every file that goes public, and publish. |
57
57
  | `arcade share <slug> [--profile] [--badge] [--json]` | Reprint the Share block publish ends with: the link, a video-description line, an X post draft, and the GIF to attach. `--profile` shares your whole library; `--badge` prints the "Play on Evolutionary Arcade" badge as Markdown and HTML. You post them; the arcade never posts for you. |
58
58
  | `arcade media preview [dir]` | Make the 12-second hover preview from your demo (needs ffmpeg). |
59
+ | `arcade media vertical [dir]` | Make the phone (9:16) media set: landscape games over a blurred fill, portrait games from their vertical demo (needs ffmpeg). |
59
60
  | `arcade main <slug> <generation>` | (Owner) Pick the main generation for a version. |
60
61
  | `arcade unpublish <slug>` / `arcade republish <slug>` | (Owner) Take your game down, or bring it back. |
61
62
  | `arcade profile show` / `set` / `avatar` / `banner` | See and edit your public profile. |
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { dirname, isAbsolute, join } from "path";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "evolutionary-arcade",
16
- version: "0.3.0",
16
+ version: "0.4.0",
17
17
  description: "The arcade CLI for Evolutionary Arcade. Publish, update, regen, fork, and blend AI-made browser games with your own coding agent.",
18
18
  type: "module",
19
19
  license: "MIT",
@@ -20592,6 +20592,14 @@ var videoPath = relPath.refine(
20592
20592
  (p) => ["mp4", "webm"].includes(ext(p)),
20593
20593
  "Use an .mp4 or .webm video."
20594
20594
  );
20595
+ var ORIENTATIONS = ["landscape", "portrait", "both"];
20596
+ var verticalMediaSchema = external_exports.object({
20597
+ thumbnail: imagePath.optional(),
20598
+ screenshots: external_exports.array(imagePath).max(12).default([]),
20599
+ demo_video: videoPath.optional(),
20600
+ preview_video: videoPath.optional(),
20601
+ preview_gif: gifPath.optional()
20602
+ });
20595
20603
  var sourceSchema = external_exports.object({
20596
20604
  repo: external_exports.string().regex(
20597
20605
  /^https:\/\/github\.com\/[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/,
@@ -20638,6 +20646,8 @@ var arcadeSchema = external_exports.object({
20638
20646
  // play: "someone remixed your game" emails and link previews. The CLI requires it whenever
20639
20647
  // there's a demo.
20640
20648
  preview_gif: gifPath.optional(),
20649
+ orientation: external_exports.enum(ORIENTATIONS).default("landscape"),
20650
+ media_vertical: verticalMediaSchema.optional(),
20641
20651
  lineage: lineageSchema.default({ kind: "original", parents: [] }),
20642
20652
  provenance: provenanceSchema.default({ subagent_models: [] }),
20643
20653
  source: sourceSchema.optional()
@@ -20649,6 +20659,20 @@ var arcadeSchema = external_exports.object({
20649
20659
  message: "A game can't be its own parent."
20650
20660
  });
20651
20661
  }
20662
+ if (a.orientation !== "landscape" && a.input.touch) {
20663
+ const v = a.media_vertical;
20664
+ const missing = [
20665
+ !v?.thumbnail && "a thumbnail",
20666
+ !v?.screenshots.length && "at least one screenshot",
20667
+ a.demo_video && !v?.demo_video && "a demo"
20668
+ ].filter((x) => typeof x === "string");
20669
+ if (missing.length)
20670
+ ctx.addIssue({
20671
+ code: "custom",
20672
+ path: ["media_vertical"],
20673
+ message: `A ${a.orientation === "both" ? "game that also plays upright" : "portrait game"} needs vertical media from real play at 390x844: ${missing.join(", ")}.`
20674
+ });
20675
+ }
20652
20676
  const ids = a.leaderboards.map((b) => b.id);
20653
20677
  ids.forEach((id, i) => {
20654
20678
  if (ids.indexOf(id) !== i)
@@ -20695,6 +20719,11 @@ var friendlyIssue = (iss) => {
20695
20719
  if (limit) return `Use up to ${limit} screenshots.`;
20696
20720
  if (missing) return "Add at least one screenshot.";
20697
20721
  break;
20722
+ case "orientation":
20723
+ return 'Use "landscape", "portrait", or "both": the way the game plays on a phone.';
20724
+ case "media_vertical.screenshots":
20725
+ if (limit) return `Use up to ${limit} vertical screenshots.`;
20726
+ break;
20698
20727
  case "lineage.kind":
20699
20728
  return "lineage.kind was changed; re-run the arcade command that made this folder.";
20700
20729
  case "lineage.parents":
@@ -21198,7 +21227,9 @@ function derived2(src, overrides) {
21198
21227
  "screenshots",
21199
21228
  "demo_video",
21200
21229
  "preview_video",
21201
- "preview_gif"
21230
+ "preview_gif",
21231
+ "orientation",
21232
+ "media_vertical"
21202
21233
  ];
21203
21234
  const out2 = { schema: "arcade/v0" };
21204
21235
  for (const k of keep) if (a[k] !== void 0) out2[k] = a[k];
@@ -21500,8 +21531,8 @@ function dev(dirArg, opts) {
21500
21531
 
21501
21532
  // src/commands/publish.ts
21502
21533
  import { execFileSync } from "child_process";
21503
- import { existsSync as existsSync6, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
21504
- import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4, sep as sep4 } from "path";
21534
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync3 } from "fs";
21535
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4, sep as sep4 } from "path";
21505
21536
 
21506
21537
  // src/files.ts
21507
21538
  import { createHash as createHash2 } from "crypto";
@@ -21585,6 +21616,7 @@ function walkGame(root) {
21585
21616
 
21586
21617
  // src/fixes.ts
21587
21618
  var CAPTURE_GUIDE = 'arcade guide publishing, "Capturing media"';
21619
+ var VERTICAL = "media/vertical";
21588
21620
  var thumbnailFix = (path) => `Run the game (arcade dev), capture a 1280x720 frame of real play, mid-action with no title screen or added text, and save it as ${path}.`;
21589
21621
  var screenshotFix = (path) => `Capture a different moment of real play at 1280x720 and save it as ${path}. Aim for 2 to 4 screenshots in all.`;
21590
21622
  var demoFix = (path) => `Record 15 to 30 s of real play at 1280x720 as H.264 and save it as ${path}, with the action starting in the first second (${CAPTURE_GUIDE}).`;
@@ -21614,6 +21646,10 @@ function fixFor(issue2, raw) {
21614
21646
  return `${screenshotFix("media/shot-1.png")} List the paths in "screenshots".`;
21615
21647
  if (path === "demo_video") return `${demoFix("media/demo.mp4")} Then set "demo_video".`;
21616
21648
  if (path === "preview_video" || path === "preview_gif") return fromDemoFix;
21649
+ if (path === "orientation")
21650
+ return 'Play the game on a phone (390x844 with touch): "landscape" if it needs the phone sideways, "portrait" if it plays upright, "both" if it works either way.';
21651
+ if (path === "media_vertical" || path.startsWith("media_vertical."))
21652
+ return `Run the game at 390x844 with touch emulation and capture upright play into ${VERTICAL}/ (thumbnail.png, shot-1.png, and demo.mp4 if the game has a landscape demo), list them under "media_vertical", then run \`arcade media vertical\` for the preview and GIF (${CAPTURE_GUIDE}). If the game really only plays sideways, set "orientation": "landscape" instead.`;
21617
21653
  if (path === "title") return `Set "title" to the game's name as players should see it.`;
21618
21654
  if (path === "description")
21619
21655
  return 'Set "description" to one or two sentences: the fantasy and the goal.';
@@ -21629,6 +21665,10 @@ function missingMediaLine(path, raw) {
21629
21665
  if (raw.demo_video === path) return `${path} (demo): ${demoFix(path)}`;
21630
21666
  if (raw.preview_video === path) return `${path} (hover preview): ${fromDemoFix}`;
21631
21667
  if (raw.preview_gif === path) return `${path} (GIF): ${fromDemoFix}`;
21668
+ const v = raw.media_vertical ?? {};
21669
+ const vertical = [v.thumbnail, v.demo_video, v.preview_video, v.preview_gif].includes(path) || Array.isArray(v.screenshots) && v.screenshots.includes(path);
21670
+ if (vertical)
21671
+ return `${path} (vertical): ${raw.orientation === "portrait" || raw.orientation === "both" ? `capture it from real play at 390x844 with touch; previews and GIFs come from \`arcade media vertical\`.` : "run `arcade media vertical` to make the vertical set from the landscape media."}`;
21632
21672
  return path;
21633
21673
  }
21634
21674
 
@@ -22020,8 +22060,9 @@ function runFfmpeg(args, what) {
22020
22060
  throw new ExitError(`ffmpeg couldn't make the ${what}${last ? `: ${last}` : "."}`);
22021
22061
  }
22022
22062
  }
22023
- function makePreview(dir, demo, target = "media/preview.mp4") {
22063
+ function makePreview(dir, demo, target = "media/preview.mp4", scale = "scale=854:-2") {
22024
22064
  if (!hasFfmpeg()) return null;
22065
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22025
22066
  runFfmpeg(
22026
22067
  [
22027
22068
  "-i",
@@ -22030,7 +22071,7 @@ function makePreview(dir, demo, target = "media/preview.mp4") {
22030
22071
  "12",
22031
22072
  "-an",
22032
22073
  "-vf",
22033
- "scale=854:-2,fps=30",
22074
+ `${scale},fps=30`,
22034
22075
  "-c:v",
22035
22076
  "libx264",
22036
22077
  "-preset",
@@ -22048,9 +22089,13 @@ function makePreview(dir, demo, target = "media/preview.mp4") {
22048
22089
  return target;
22049
22090
  }
22050
22091
  var MAX_GIF_BYTES = 5 * 1024 * 1024;
22051
- function makeGif(dir, demo, target = "media/preview.gif") {
22092
+ function makeGif(dir, demo, target = "media/preview.gif", vertical = false) {
22052
22093
  if (!hasFfmpeg()) return null;
22053
- const tries = [
22094
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22095
+ const tries = vertical ? [
22096
+ { width: 360, fps: 12, seconds: 6 },
22097
+ { width: 270, fps: 10, seconds: 5 }
22098
+ ] : [
22054
22099
  { width: 480, fps: 12, seconds: 6 },
22055
22100
  { width: 360, fps: 10, seconds: 5 }
22056
22101
  ];
@@ -22076,6 +22121,155 @@ function makeGif(dir, demo, target = "media/preview.gif") {
22076
22121
  `The GIF came out over ${MAX_GIF_BYTES / 1024 / 1024} MB, even small. Re-cut the demo so its first 5 seconds are less busy (a later -ss), then run \`arcade media preview\` again.`
22077
22122
  );
22078
22123
  }
22124
+ var VERTICAL_W = 1080;
22125
+ var VERTICAL_H = 1920;
22126
+ var BLURRED_FILL = [
22127
+ "split=2[bg][fg]",
22128
+ `[bg]scale=${VERTICAL_W}:${VERTICAL_H}:force_original_aspect_ratio=increase,crop=${VERTICAL_W}:${VERTICAL_H},scale=270:480,gblur=sigma=10,scale=${VERTICAL_W}:${VERTICAL_H},eq=brightness=-0.22:saturation=1.1[b]`,
22129
+ `[fg]scale=${VERTICAL_W}:-2[f]`,
22130
+ "[b][f]overlay=(W-w)/2:(H-h)/2"
22131
+ ].join(";");
22132
+ var MAX_VIDEO_BYTES = 25 * 1024 * 1024;
22133
+ var VERTICAL_DIR = "media/vertical";
22134
+ function fillImage(dir, src, target) {
22135
+ if (!hasFfmpeg()) return null;
22136
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22137
+ runFfmpeg(
22138
+ [
22139
+ "-i",
22140
+ join6(dir, src),
22141
+ "-filter_complex",
22142
+ BLURRED_FILL,
22143
+ "-frames:v",
22144
+ "1",
22145
+ "-q:v",
22146
+ "3",
22147
+ join6(dir, target)
22148
+ ],
22149
+ `vertical version of ${src}`
22150
+ );
22151
+ return target;
22152
+ }
22153
+ function fillDemo(dir, demo, target = `${VERTICAL_DIR}/demo.mp4`) {
22154
+ if (!hasFfmpeg()) return null;
22155
+ mkdirSync3(dirname3(join6(dir, target)), { recursive: true });
22156
+ for (const crf of ["28", "34"]) {
22157
+ runFfmpeg(
22158
+ [
22159
+ "-i",
22160
+ join6(dir, demo),
22161
+ "-t",
22162
+ "30",
22163
+ "-an",
22164
+ "-filter_complex",
22165
+ `${BLURRED_FILL},fps=30,format=yuv420p`,
22166
+ "-c:v",
22167
+ "libx264",
22168
+ "-preset",
22169
+ "veryfast",
22170
+ "-crf",
22171
+ crf,
22172
+ "-movflags",
22173
+ "+faststart",
22174
+ join6(dir, target)
22175
+ ],
22176
+ "vertical demo"
22177
+ );
22178
+ if (statSync5(join6(dir, target)).size <= MAX_VIDEO_BYTES) return target;
22179
+ }
22180
+ throw new ExitError(
22181
+ `The vertical demo came out over ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter demo (15 to 20 s), then run \`arcade media vertical\` again.`
22182
+ );
22183
+ }
22184
+ var orientationOf = (raw) => raw.orientation === "portrait" || raw.orientation === "both" ? raw.orientation : "landscape";
22185
+ var verticalOf = (raw) => raw.media_vertical && typeof raw.media_vertical === "object" ? { ...raw.media_vertical } : {};
22186
+ var exists = (dir, p) => typeof p === "string" && existsSync6(join6(dir, p));
22187
+ function fillVerticalSet(dir, raw) {
22188
+ const v = verticalOf(raw);
22189
+ const made = [];
22190
+ const skipped = [];
22191
+ const image = (src, target) => {
22192
+ try {
22193
+ made.push(fillImage(dir, src, target));
22194
+ return target;
22195
+ } catch (e) {
22196
+ if (!(e instanceof ExitError)) throw e;
22197
+ skipped.push(e.message);
22198
+ return null;
22199
+ }
22200
+ };
22201
+ if (exists(dir, raw.thumbnail)) {
22202
+ const t = image(raw.thumbnail, `${VERTICAL_DIR}/thumbnail.jpg`);
22203
+ if (t) v.thumbnail = t;
22204
+ }
22205
+ if (Array.isArray(raw.screenshots)) {
22206
+ v.screenshots = raw.screenshots.filter((p) => exists(dir, p)).map((p, i) => image(p, `${VERTICAL_DIR}/shot-${i + 1}.jpg`)).filter((p) => p !== null);
22207
+ }
22208
+ if (exists(dir, raw.demo_video)) {
22209
+ v.demo_video = fillDemo(dir, raw.demo_video);
22210
+ v.preview_video = makePreview(
22211
+ dir,
22212
+ v.demo_video,
22213
+ `${VERTICAL_DIR}/preview.mp4`,
22214
+ "scale=-2:854"
22215
+ );
22216
+ v.preview_gif = makeGif(dir, v.demo_video, `${VERTICAL_DIR}/preview.gif`, true);
22217
+ made.push(v.demo_video, v.preview_video, v.preview_gif);
22218
+ }
22219
+ if (made.length) raw.media_vertical = v;
22220
+ return { made, skipped };
22221
+ }
22222
+ function ensureVertical(dir, raw) {
22223
+ const v = verticalOf(raw);
22224
+ const save = () => writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22225
+ `);
22226
+ if (orientationOf(raw) === "landscape") {
22227
+ const needDemo = exists(dir, raw.demo_video) && !v.demo_video;
22228
+ const needThumb = exists(dir, raw.thumbnail) && !v.thumbnail;
22229
+ if (!needDemo && !needThumb) return;
22230
+ if (!hasFfmpeg()) {
22231
+ if (needDemo)
22232
+ throw new ExitError(
22233
+ "This game has a demo, so phones need a vertical cut of it (over a blurred fill), and that takes ffmpeg.",
22234
+ 2,
22235
+ [NO_FFMPEG],
22236
+ "Then run `arcade media vertical` (or publish again, which makes it for you)."
22237
+ );
22238
+ out(
22239
+ dim("No vertical thumbnail: ffmpeg isn't installed. Phones will show the landscape one.")
22240
+ );
22241
+ return;
22242
+ }
22243
+ const { made, skipped } = fillVerticalSet(dir, raw);
22244
+ if (made.length) save();
22245
+ if (made.length)
22246
+ out(
22247
+ dim(
22248
+ `Made the vertical set for phones, over a blurred fill (${plural(made.length, "file")} in ${VERTICAL_DIR}/).`
22249
+ )
22250
+ );
22251
+ for (const m of skipped)
22252
+ out(yellow(`No vertical copy: ${m} Phones will show the landscape one.`));
22253
+ return;
22254
+ }
22255
+ if (exists(dir, v.demo_video) && !v.preview_gif) {
22256
+ if (!hasFfmpeg())
22257
+ throw new ExitError("The vertical demo needs a GIF made from it, and that takes ffmpeg.", 2, [
22258
+ NO_FFMPEG
22259
+ ]);
22260
+ const made = cutVerticalPreviews(dir, raw);
22261
+ save();
22262
+ out(dim(`Made a vertical preview and GIF from the vertical demo (${made.join(", ")}).`));
22263
+ }
22264
+ }
22265
+ function cutVerticalPreviews(dir, raw) {
22266
+ const v = verticalOf(raw);
22267
+ if (!exists(dir, v.demo_video)) return [];
22268
+ v.preview_video = makePreview(dir, v.demo_video, `${VERTICAL_DIR}/preview.mp4`, "scale=-2:854");
22269
+ v.preview_gif = makeGif(dir, v.demo_video, `${VERTICAL_DIR}/preview.gif`, true);
22270
+ raw.media_vertical = v;
22271
+ return [v.preview_video, v.preview_gif];
22272
+ }
22079
22273
  var NO_FFMPEG = "ffmpeg isn't installed. Install it (macOS: `brew install ffmpeg`, Ubuntu: `sudo apt install ffmpeg`, Windows: `winget install ffmpeg`) and try again.";
22080
22274
  function demoFor(dir, raw, what) {
22081
22275
  if (typeof raw.demo_video !== "string")
@@ -22116,6 +22310,36 @@ async function mediaGif(dirArg) {
22116
22310
  `);
22117
22311
  out(`Made ${gif} (${mb(statSync5(join6(dir, gif)).size)}) and added it to arcade.json.`);
22118
22312
  }
22313
+ async function mediaVertical(dirArg) {
22314
+ const dir = resolve4(dirArg ?? ".");
22315
+ const raw = readArcadeRaw(dir);
22316
+ if (!hasFfmpeg()) throw new ExitError(NO_FFMPEG);
22317
+ const orientation = orientationOf(raw);
22318
+ let made;
22319
+ if (orientation === "landscape") {
22320
+ if (!exists(dir, raw.thumbnail))
22321
+ throw new ExitError(
22322
+ `The vertical set is made from the landscape media, and ${typeof raw.thumbnail === "string" ? `${raw.thumbnail} isn't in the folder yet` : "arcade.json has no thumbnail"}. Capture the landscape media first (${CAPTURE_GUIDE}).`,
22323
+ 2
22324
+ );
22325
+ const r = fillVerticalSet(dir, raw);
22326
+ made = r.made;
22327
+ for (const m of r.skipped) out(yellow(`Skipped: ${m}`));
22328
+ } else {
22329
+ made = cutVerticalPreviews(dir, raw);
22330
+ if (!made.length)
22331
+ throw new ExitError(
22332
+ `This game plays ${orientation === "portrait" ? "upright" : "both ways"}, so its vertical media comes from real upright play, not a blurred fill. Record 15 to 30 s at 390x844 with touch into ${VERTICAL_DIR}/demo.mp4, set "media_vertical.demo_video", then run this again (${CAPTURE_GUIDE}).`,
22333
+ 2
22334
+ );
22335
+ }
22336
+ writeFileSync3(join6(dir, "arcade.json"), `${JSON.stringify(raw, null, 2)}
22337
+ `);
22338
+ out(
22339
+ `Made ${plural(made.length, "vertical file")} (${orientation === "landscape" ? "landscape media over a blurred fill, 1080x1920" : "cut from your vertical demo"}) and set media_vertical:`
22340
+ );
22341
+ for (const m of made) out(` ${m} ${dim(mb(statSync5(join6(dir, m)).size))}`);
22342
+ }
22119
22343
  function describeAction(p, parentNames) {
22120
22344
  switch (p.action) {
22121
22345
  case "original":
@@ -22175,6 +22399,19 @@ function listWords(words) {
22175
22399
  if (words.length <= 1) return words.join("");
22176
22400
  return `${words.slice(0, -1).join(", ")}${words.length > 2 ? "," : ""} and ${words.at(-1)}`;
22177
22401
  }
22402
+ var FULLSCREEN_CALL = /\b(?:request|webkitRequest|mozRequest|msRequest)Full[sS]creen\b/;
22403
+ var CODE_FILE = /\.(?:html?|m?js|jsx|tsx?)$/i;
22404
+ function fullscreenCalls(files) {
22405
+ const hits = [];
22406
+ for (const f of files) {
22407
+ if (!CODE_FILE.test(f.path) || f.path.startsWith("parents/") || f.size > 2 * 1024 * 1024)
22408
+ continue;
22409
+ const lines = readFileSync8(f.abs, "utf8").split("\n");
22410
+ const i = lines.findIndex((l) => FULLSCREEN_CALL.test(l));
22411
+ if (i >= 0) hits.push(`${f.path}:${i + 1}`);
22412
+ }
22413
+ return hits;
22414
+ }
22178
22415
  var HOME_PATH = /(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s/\\"'`]+/;
22179
22416
  function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
22180
22417
  const notes = [];
@@ -22261,6 +22498,26 @@ function headsUp(arcade, files, alreadyOnArcade, secretWarnings, dir) {
22261
22498
  notes.push(
22262
22499
  `The ${stale.map(([w]) => w).join(" and ")} ${stale.length > 1 ? "are" : "is"} older than the demo. Run \`arcade media preview\` to remake ${stale.length > 1 ? "them" : "it"}.`
22263
22500
  );
22501
+ const v = arcade.media_vertical;
22502
+ if (arcade.orientation === "landscape") {
22503
+ if (!v?.thumbnail)
22504
+ notes.push(
22505
+ "There's no vertical thumbnail, so phones show the landscape one. Run `arcade media vertical` (it needs ffmpeg)."
22506
+ );
22507
+ const vStale = [
22508
+ ["vertical thumbnail", age(v?.thumbnail), age(arcade.thumbnail)],
22509
+ ["vertical demo", age(v?.demo_video), demoAt]
22510
+ ].filter(([, t, src]) => t !== null && src !== null && t < src);
22511
+ if (vStale.length)
22512
+ notes.push(
22513
+ `The ${vStale.map(([w]) => w).join(" and ")} ${vStale.length > 1 ? "are" : "is"} older than the landscape media. Run \`arcade media vertical\` to remake the vertical set.`
22514
+ );
22515
+ }
22516
+ const fullscreen = fullscreenCalls(files);
22517
+ if (fullscreen.length)
22518
+ notes.push(
22519
+ `${listWords(fullscreen.slice(0, 3))}${fullscreen.length > 3 ? ` and ${fullscreen.length - 3} more` : ""} ${fullscreen.length > 1 ? "call" : "calls"} requestFullscreen: the arcade's player owns fullscreen; remove your fullscreen button.`
22520
+ );
22264
22521
  const thumb = files.find((f) => f.path === arcade.thumbnail);
22265
22522
  if (thumb && thumb.size > 500 * 1024) {
22266
22523
  const webp = arcade.thumbnail.replace(/\.[^.]+$/, ".webp");
@@ -22344,6 +22601,7 @@ async function publish(dirArg, opts) {
22344
22601
  `);
22345
22602
  out(dim(`Made a GIF from the demo (${made}, ${mb(statSync5(join6(dir, made)).size)}).`));
22346
22603
  }
22604
+ ensureVertical(dir, raw);
22347
22605
  const prov = raw.provenance ?? {};
22348
22606
  const orch = prov.orchestrator ?? null;
22349
22607
  const named = opts.session ?? [];
@@ -22396,6 +22654,14 @@ async function publish(dirArg, opts) {
22396
22654
  throw new ExitError("A game with a demo needs a GIF made from it (preview_gif).", 2, [
22397
22655
  `${arcade.demo_video} (demo): record it first, then run \`arcade media preview\`.`
22398
22656
  ]);
22657
+ if (arcade.media_vertical?.demo_video && !arcade.media_vertical.preview_gif)
22658
+ throw new ExitError(
22659
+ "The vertical demo needs a GIF made from it (media_vertical.preview_gif).",
22660
+ 2,
22661
+ [
22662
+ `${arcade.media_vertical.demo_video} (vertical demo): record it first, then run \`arcade media vertical\`.`
22663
+ ]
22664
+ );
22399
22665
  await fillLicenseHolder(dir);
22400
22666
  const { files, skipped, skippedLinks } = walkGame(dir);
22401
22667
  const bad = files.filter((f) => !f.type);
@@ -22413,7 +22679,12 @@ async function publish(dirArg, opts) {
22413
22679
  ...arcade.screenshots,
22414
22680
  arcade.demo_video,
22415
22681
  arcade.preview_video,
22416
- arcade.preview_gif
22682
+ arcade.preview_gif,
22683
+ arcade.media_vertical?.thumbnail,
22684
+ ...arcade.media_vertical?.screenshots ?? [],
22685
+ arcade.media_vertical?.demo_video,
22686
+ arcade.media_vertical?.preview_video,
22687
+ arcade.media_vertical?.preview_gif
22417
22688
  ];
22418
22689
  const missingMedia = media.filter((m) => !!m && !files.some((f) => f.path === m));
22419
22690
  if (missingMedia.length) {
@@ -22705,12 +22976,12 @@ async function setPublished(slug, published) {
22705
22976
  }
22706
22977
 
22707
22978
  // src/commands/skills.ts
22708
- import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
22979
+ import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
22709
22980
  import { homedir as homedir4 } from "os";
22710
- import { dirname as dirname3, join as join7, resolve as resolve5 } from "path";
22981
+ import { dirname as dirname4, join as join7, resolve as resolve5 } from "path";
22711
22982
  import { fileURLToPath } from "url";
22712
22983
  function skillsDir() {
22713
- const here = dirname3(fileURLToPath(import.meta.url));
22984
+ const here = dirname4(fileURLToPath(import.meta.url));
22714
22985
  for (const candidate2 of [join7(here, "..", "skills"), join7(here, "..", "..", "skills")]) {
22715
22986
  if (existsSync7(join7(candidate2, "arcade-getting-started", "SKILL.md"))) return candidate2;
22716
22987
  }
@@ -22763,7 +23034,7 @@ function installSkills(opts) {
22763
23034
  return f();
22764
23035
  });
22765
23036
  for (const target of targets) {
22766
- mkdirSync3(target, { recursive: true });
23037
+ mkdirSync4(target, { recursive: true });
22767
23038
  for (const name of names)
22768
23039
  cpSync(join7(src, name), join7(target, name), { recursive: true, force: true });
22769
23040
  out(`Installed ${names.length} skills into ${target}`);
@@ -22868,6 +23139,9 @@ function buildProgram() {
22868
23139
  "remake media/preview.mp4 (12 s) and media/preview.gif (6 s) from your demo, after every new demo"
22869
23140
  ).action(mediaPreview);
22870
23141
  media.command("gif [dir]").description("remake only media/preview.gif from your demo").action(mediaGif);
23142
+ media.command("vertical [dir]").description(
23143
+ "make the phone (9:16) set in media/vertical/: landscape games get their media over a blurred fill at 1080x1920; portrait and both games get a preview and GIF from their vertical demo"
23144
+ ).action(mediaVertical);
22871
23145
  program.command("main <slug> <generation>").description("(owner) pick which generation is main for its version").action(setMain);
22872
23146
  program.command("unpublish <slug>").description("(owner) take your game down").action((slug) => setPublished(slug, false));
22873
23147
  program.command("republish <slug>").description("(owner) bring your game back").action((slug) => setPublished(slug, true));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolutionary-arcade",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The arcade CLI for Evolutionary Arcade. Publish, update, regen, fork, and blend AI-made browser games with your own coding agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,7 +59,7 @@ someone else's game, see `arcade-remix-and-blend`.
59
59
  files by relative URL is fine. `arcade dev` lets `ws:` through but the arcade doesn't, so don't
60
60
  trust dev for sockets. Don't add tracking code; the arcade measures play from outside the frame.
61
61
  4. **Start on a click or keypress.** Show a title screen with "Click to play". Create or resume the
62
- `AudioContext` inside that handler. Request pointer lock and fullscreen only from a gesture. Esc
62
+ `AudioContext` inside that handler. Request pointer lock only from a gesture. Esc
63
63
  and gamepad buttons don't count as gestures, so a controller-only player can't turn on sound.
64
64
  Show "click or press any key once."
65
65
  5. **Pointer lock comes from the sandbox token.** The arcade grants `allow-pointer-lock`, and Chrome
@@ -97,7 +97,15 @@ someone else's game, see `arcade-remix-and-blend`.
97
97
  like every game here. Anything you do bundle from others, like a vendored Three.js, keeps its
98
98
  own license: ship its LICENSE beside it (`vendor/three/LICENSE`), and only bundle what's yours
99
99
  to share.
100
- 10. **Storage is optional, and shared.** `localStorage` works (a saved high score is nice), but wrap
100
+ 10. **Don't build fullscreen. The platform owns it.** The arcade's player has the fullscreen
101
+ button, and on phones the game page's Play button opens the game straight into it. It uses
102
+ the Fullscreen API where there is one, covers the whole page on iPhone (which has none for
103
+ games), and locks the orientation on Android. So no
104
+ fullscreen button, no `requestFullscreen()` or `webkitRequestFullscreen()`, and no
105
+ `screen.orientation.lock()` of your own. Size the game to its frame (`resize` events) and it
106
+ works full-size. `arcade publish` warns when your code calls requestFullscreen: "the arcade's
107
+ player owns fullscreen; remove your fullscreen button".
108
+ 11. **Storage is optional, and shared.** `localStorage` works (a saved high score is nice), but wrap
101
109
  every access in try/catch. Every version and generation of a game shares one origin, including
102
110
  other people's regens. Prefix keys with your slug, version your save format, and handle old or
103
111
  malformed data. To keep progress across devices, use profile saves (below).
@@ -259,7 +267,7 @@ a game's history is a record of what each model could build.
259
267
  - **Touch:** without `input.touch: true`, phones get your demo video instead of the game. Set it
260
268
  only if the game has real touch controls, like an on-screen stick and buttons with large targets
261
269
  (48 px or more). Use pointer events, put `touch-action: none` on the canvas, and don't rely on
262
- hover. Test at phone width, where the 16:9 frame is tiny until the player goes fullscreen. To
270
+ hover. Test at phone width (390x844 with touch), where the player opens fullscreen. To
263
271
  try it on a real phone, run `arcade dev --host` and open the network address it prints on a
264
272
  phone on the same Wi-Fi.
265
273
  - **Tilt:** see Phone tilt, next.
@@ -282,21 +290,18 @@ that have touch on, and every tilt game needs touch controls anyway.
282
290
  little. Put "Recenter" in the pause menu.
283
291
  - **Turn the axes with the screen.** `gamma` is left-right only in portrait. In landscape it's
284
292
  `beta`, and the sign flips with the side the phone is turned to (`screen.orientation.angle`).
285
- - **Landscape lock only works in fullscreen on Android.** The arcade's Fullscreen button already
286
- locks landscape there. A game that goes fullscreen from its own tap can lock too (a portrait
287
- game can lock `"portrait"`). iPhones never lock and have no fullscreen for games, so show
288
- "Turn your phone sideways" while a landscape game is held upright.
293
+ - **Orientation is the platform's job.** Set `orientation` in arcade.json (`"landscape"`,
294
+ `"portrait"`, or `"both"`) and don't lock it yourself; the arcade's fullscreen player locks
295
+ the screen on Android. iPhones never lock, so a landscape game shows "Turn your phone sideways"
296
+ while it's held upright.
289
297
 
290
298
  ```js
291
- const LANDSCAPE = true; // a landscape-first game
292
299
  let tiltOk = false, zero = null, tilt = 0; // tilt: -1..1; steer by touch until tiltOk
293
300
  async function onStartTap() { // the "Tap to play" handler
294
301
  const DOE = globalThis.DeviceOrientationEvent;
295
302
  const asking = typeof DOE?.requestPermission === "function"
296
303
  ? DOE.requestPermission().catch(() => "denied") // iPhone: asks, so call it before any await
297
304
  : Promise.resolve(DOE ? "granted" : "denied");
298
- if (LANDSCAPE) document.documentElement.requestFullscreen?.()
299
- .then(() => screen.orientation.lock("landscape")).catch(() => {}); // Android only
300
305
  tiltOk = (await asking) === "granted";
301
306
  zero = null; // "Recenter" does this too
302
307
  startOrResume();
@@ -55,7 +55,7 @@ Play-test the idea before you touch code. Ask these together, in one message, an
55
55
  > 2. What should stay? The thing you liked most about the original. (default: its core feel)
56
56
  > 3. A name for your game? It becomes its address, evolutionaryarcade.com/g/<name>, and can't change later. (default: I'll suggest one from your idea)
57
57
  > 4. Phones: want touch controls so people watching on their phones can play it? (default: yes, if the original doesn't have them)
58
- > 5. Want me to record a short demo so your game page has a video and a GIF to share? (default: yes)"
58
+ > 5. Want me to record a short demo so your game page has a video and a GIF to share? It comes in two shapes, wide for computers and tall for phones. (default: yes)"
59
59
 
60
60
  **For a blend**, replace question 1 and 2 with: "Which game's loop is the spine? I'll assume <spine>, with <donor> giving one thing: its movement, its look, an enemy, a mechanic. Which thing do you want from <donor>?" and say in one sentence what the blend would be ("Fly a starfighter to defend a deep-space relay from drone waves"). If they can't say it without "and also", it's two games; help them pick one.
61
61
 
@@ -105,9 +105,11 @@ Play it after every meaningful change, with real inputs through Playwright again
105
105
 
106
106
  ## Step 6. Media, demo, and GIF
107
107
 
108
- Capture new media from **their** build: a fork arrives with the parent's files, and a card showing the parent's footage misrepresents the game. A 1280x720 thumbnail mid-action and 2-4 screenshots from real play (`arcade guide publishing`, "Capturing media").
108
+ Estimate (say it's an estimate): about 5 minutes for a landscape game's media and demo, the vertical set included, since `arcade media vertical` makes it; about 10 for a portrait or both game, which records upright play too.
109
109
 
110
- If they said yes to a demo (the default): record 15-30 s of real play at 1280x720 with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes the hover preview and `media/preview.gif`. Look at the GIF before moving on. Keep raw recordings outside the game folder; everything in it is uploaded.
110
+ Capture new media from **their** build: a fork arrives with the parent's files, and a card showing the parent's footage misrepresents the game. A 1280x720 thumbnail mid-action and 2-4 screenshots from real play, plus the vertical (phone) set in the same pass: check `orientation` still fits their version (play it at 390x844 with touch), then capture upright play into `media/vertical/` for a portrait or both game, or run `arcade media vertical` for a landscape one, which puts the landscape media over a blurred fill (`arcade guide publishing`, "Capturing media" and "Orientation and the vertical set"). The parent's vertical files are the parent's footage too, so remake them. If the game has its own fullscreen button, remove it: the arcade's player owns fullscreen.
111
+
112
+ If they said yes to a demo (the default): record 15-30 s of real play at 1280x720 with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes the hover preview and `media/preview.gif`. For a portrait or both game, also record upright play at 390x844 into `media/vertical/demo.mp4`; then run `arcade media vertical` either way. Look at both GIFs before moving on. Keep raw recordings outside the game folder; everything in it is uploaded.
111
113
 
112
114
  ## Step 7. One dry run, one go, publish
113
115
 
@@ -97,7 +97,7 @@ For each candidate, decide from facts, then build one numbered table. It goes in
97
97
 
98
98
  Verdicts: **ready** (publishes as-is once it has metadata and media), **small fixes** (absolute paths, a CDN import to vendor, a build step, a stray big file), **not a fit** (needs a server, no web build, mostly someone else's work). Give the reason in a few words.
99
99
 
100
- **Time estimates are yours.** Look at the code before you estimate, and say they're estimates. As a starting point: as-is with metadata and media takes you a few minutes per game; touch controls 10-20 min; gamepad 5-10; saves or a leaderboard 5-10 each; a demo 3-5. Adjust for what you see.
100
+ **Time estimates are yours.** Look at the code before you estimate, and say they're estimates. As a starting point: as-is with metadata and media takes you a few minutes per game, including the vertical (phone) set: about 1 more minute for a landscape game (`arcade media vertical` makes it), 3-5 for a portrait or both game (an upright capture at 390x844); touch controls 10-20 min; gamepad 5-10; saves or a leaderboard 5-10 each; a demo 3-5 (twice that for a portrait or both game, which records a vertical demo too); removing a game's own fullscreen button 2-5. Adjust for what you see.
101
101
 
102
102
  ```
103
103
  # Game Where Fit What it needs As-is / optimized
@@ -117,7 +117,9 @@ Follow their step-1 answer for the whole batch.
117
117
  - write `arcade.json` (`arcade guide publishing` has a full example): a slug you pick with them (permanent, it's the game's address), the title, a one-or-two-sentence description, exact `controls`, and honest `input` flags;
118
118
  - write `LICENSE` (MIT, "Copyright (c) <year> @<handle>") unless they already have an MIT one;
119
119
  - run it with `arcade dev`, fix anything that breaks under the arcade's CSP (outside requests, absolute paths);
120
- - capture a 1280x720 thumbnail mid-action and 2-4 screenshots from real play (`arcade guide publishing`, "Capturing media");
120
+ - decide its `orientation` by playing it at 390x844 with touch: `"landscape"` if it needs the phone sideways (fine), `"portrait"` if it plays upright, `"both"` if either works;
121
+ - capture a 1280x720 thumbnail mid-action and 2-4 screenshots from real play, and in the same pass the vertical set: for portrait or both, an upright thumbnail and 2-4 screenshots at 390x844 with touch emulation into `media/vertical/`; for landscape, run `arcade media vertical`, which puts the landscape media over a blurred fill (`arcade guide publishing`, "Capturing media" and "Orientation and the vertical set");
122
+ - remove any fullscreen button of the game's own and its `requestFullscreen` calls: the arcade's player owns fullscreen (the dry run warns about them);
121
123
  - fill the Model card with what they tell you (the model and harness they used); leave out anything unknown. Never invent numbers.
122
124
 
123
125
  **Optimize** adds, per game, only what makes sense for it, using the arcade's format (`arcade guide building-games` has each one):
@@ -131,7 +133,7 @@ Play every game after you change it. Set an input flag only when that path works
131
133
 
132
134
  ## Step 6. Demos
133
135
 
134
- If they said yes in step 1 (the default), then for each game: play it with Playwright at 1280x720 against `arcade dev` (drive real inputs, or the game's autopilot if it has one), cut 15-30 s with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes `media/preview.mp4` (the hover preview) and `media/preview.gif`, and sets both fields. **A game with a demo must have its GIF**; `arcade publish` makes it when it's missing and refuses without ffmpeg. Watch the clips and look at the GIF before moving on. Bot-driven footage is fine; say so in `provenance.notes`. Keep recordings outside the game folder, since everything in it is uploaded. Recording needs Playwright and ffmpeg; if they're missing, ask before installing.
136
+ If they said yes in step 1 (the default), then for each game: play it with Playwright at 1280x720 against `arcade dev` (drive real inputs, or the game's autopilot if it has one), cut 15-30 s with the action in the first second, save it as `media/demo.mp4`, set `"demo_video"`, and run `arcade media preview`. That makes `media/preview.mp4` (the hover preview) and `media/preview.gif`, and sets both fields. Then the vertical demo: a portrait or both game records upright play at 390x844 with touch into `media/vertical/demo.mp4` (set `media_vertical.demo_video`); every game then runs `arcade media vertical` (for a landscape game it makes the whole vertical set from the landscape demo over a blurred fill). **A game with a demo must have its GIF**; `arcade publish` makes it when it's missing and refuses without ffmpeg. Watch the clips and look at the GIF before moving on. Bot-driven footage is fine; say so in `provenance.notes`. Keep recordings outside the game folder, since everything in it is uploaded. Recording needs Playwright and ffmpeg; if they're missing, ask before installing.
135
137
 
136
138
  ## Step 7. One dry run, one go, publish
137
139
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: arcade-publishing
3
- description: Use when a browser game is ready, or nearly ready, to go live on Evolutionary Arcade (evolutionaryarcade.com) with the `arcade` CLI, or when you are shipping the next version of a game you already published. Covers filling in arcade.json and replacing the CLI's placeholder text, writing an honest Model card (models, harness, tokens, cost, prompts), capturing the thumbnail, screenshots, demo, and hover preview with Playwright and ffmpeg, reviewing `arcade publish --dry-run` and its Heads up list with the user before `arcade publish --yes`, fixing problems (exit code 2) including a flagged secret, finding generation ids with `arcade info` for `arcade main`, and taking a game down with `arcade unpublish`.
3
+ description: Use when a browser game is ready, or nearly ready, to go live on Evolutionary Arcade (evolutionaryarcade.com) with the `arcade` CLI, or when you are shipping the next version of a game you already published. Covers filling in arcade.json and replacing the CLI's placeholder text, writing an honest Model card (models, harness, tokens, cost, prompts), deciding the game's orientation and capturing both media sets (landscape at 1280x720, vertical at 390x844 or the CLI's blurred fill) with Playwright and ffmpeg, reviewing `arcade publish --dry-run` and its Heads up list with the user before `arcade publish --yes`, fixing problems (exit code 2) including a flagged secret, finding generation ids with `arcade info` for `arcade main`, and taking a game down with `arcade unpublish`.
4
4
  ---
5
5
 
6
6
  # Publishing a game to Evolutionary Arcade
@@ -16,6 +16,7 @@ Not playable under the arcade's rules yet? Use `arcade-building-games`. First ti
16
16
  ## What good looks like
17
17
 
18
18
  - The thumbnail is a real frame of play, mid-action, 1280x720.
19
+ - Phones get a vertical set that tells the truth about how the game plays: real upright play for a portrait game, or the landscape footage over a blurred fill for a game that only plays sideways.
19
20
  - The hover preview comes from this build's demo, and its first seconds show the game doing its best thing. No title screen, no loading.
20
21
  - A stranger understands the title and description, and the controls are exact.
21
22
  - The Model card is honest: filled in where you know, left out where you don't.
@@ -35,6 +36,8 @@ Required: `schema`, `slug`, `title`, `description`, `thumbnail`, and at least on
35
36
  - `profile_saves` (default `false`): the game saves progress to the player's profile with the `arcade-saves.js` helper. Set it only when the game uses the helper; `arcade-building-games` has the rules.
36
37
  - `leaderboards` (up to 4, default none): global leaderboards the game posts to with the `arcade-scores.js` helper. Each has an `id`, a `label`, and a `max`, and the dry run lists them with their limits. `arcade-building-games` has the rules.
37
38
  - `thumbnail` and `screenshots` (1 to 12) are .png, .jpg, or .webp. `demo_video` and `preview_video` are .mp4 or .webm. `preview_gif` is a .gif cut from the demo, required whenever there's a demo; `arcade media preview` (or `arcade publish`) makes it. Every path is relative to the folder, with no `..`.
39
+ - `orientation` (default `"landscape"`): which way up the game plays on a phone. `"landscape"`, `"portrait"`, or `"both"`. See "Orientation and the vertical set" below.
40
+ - `media_vertical` (optional): the phone (9:16) set, with the same fields as the landscape set: `thumbnail`, `screenshots` (up to 12), `demo_video`, `preview_video`, `preview_gif`. The top-level fields are the landscape set. A `portrait` or `both` game with `touch` must list a vertical thumbnail and at least one vertical screenshot, plus a vertical demo when it has a landscape demo. A landscape game's set is made by `arcade media vertical`.
38
41
  - `license` (optional): every game on the arcade is open source under MIT, so leave it out or set it to `"MIT"`. Anything else fails validation. The folder's `LICENSE` names the creator ("Copyright (c) <year> @handle"); if `arcade new` wrote it before you logged in, `arcade publish` fills in the handle. A license file at the top of the folder (`LICENSE`, `LICENSE.md`, `COPYING`, and the like) that isn't MIT stops the publish; other people's licenses go in `licenses/<name>/` or beside their code.
39
42
  - `lineage`: the CLI writes it. Don't edit it.
40
43
  - `provenance`: the Model card.
@@ -120,17 +123,52 @@ ffmpeg -ss 4 -i ../rec/<file>.webm -t 24 -an -vf scale=1280:720 \
120
123
  - **Budget:** the whole upload must stay under 50 MB and 800 files. The thumbnail and each screenshot must be at most 5 MB, and the demo, preview, and any other file at most 25 MB. A 24-second demo at CRF 23 is about 8 MB.
121
124
  - **Look at every file** before you publish. For video, a contact sheet is quick: `ffmpeg -i media/demo.mp4 -vf fps=1/3,scale=320:-1,tile=4x3 -frames:v 1 ../rec/sheet.png` (one image, covering 36 s). If headless WebGL renders black, run headed or pass GPU flags to Chromium (on macOS the seeds used `--use-angle=metal --ignore-gpu-blocklist`).
122
125
 
126
+ ## Orientation and the vertical set
127
+
128
+ Phones are a big share of players, and a 16:9 card is a sliver on a phone held upright. So every game ships two media sets: landscape (the top-level fields) and vertical (`media_vertical`). The site shows phones the vertical set. One size doesn't fit all, and the vertical set has to be honest about how the game plays.
129
+
130
+ **Decide the orientation by playing it on a phone.** Run `arcade dev`, open it in Playwright at 390x844 with touch emulation (or on a real phone), and play:
131
+ - `"portrait"`: it plays upright and was built for it (a tall playfield, thumb controls at the bottom).
132
+ - `"both"`: it lays out and plays well either way.
133
+ - `"landscape"`: it needs the phone sideways. That's fine. Don't fake an upright mode.
134
+ A game without touch controls is `"landscape"` unless you add them.
135
+
136
+ **Capture both sets in one pass.** Same build, same `arcade dev` server, two contexts:
137
+
138
+ ```js
139
+ // Landscape: as above, 1280x720.
140
+ // Vertical (portrait and both games): a phone, with touch.
141
+ const phone = await browser.newContext({
142
+ viewport: { width: 390, height: 844 },
143
+ deviceScaleFactor: 3, // screenshots come out 1170x2532
144
+ isMobile: true,
145
+ hasTouch: true,
146
+ recordVideo: { dir: "../rec/vertical", size: { width: 390, height: 844 } },
147
+ });
148
+ const p = await phone.newPage();
149
+ await p.goto("http://localhost:5173/?autopilot");
150
+ await p.tap("canvas"); // a tap, not a click
151
+ ```
152
+
153
+ Encode the vertical demo at 1080x1920: `ffmpeg -ss 4 -i ../rec/vertical/<file>.webm -t 24 -an -vf scale=1080:1920 -c:v libx264 -pix_fmt yuv420p -crf 23 -movflags +faststart media/vertical/demo.mp4`.
154
+
155
+ **Choose the vertical treatment by orientation:**
156
+ - **portrait or both:** real upright play. Save the vertical thumbnail and 2 to 4 screenshots in `media/vertical/`, the demo as `media/vertical/demo.mp4`, list them under `media_vertical`, then run `arcade media vertical`: it cuts the vertical hover preview (480x854) and GIF from your vertical demo. For `both`, still capture the landscape set at 1280x720; desktop shows it.
157
+ - **landscape:** capture only the landscape set, then run `arcade media vertical`. It centers each landscape image and the demo at full width over a blurred, darkened copy of itself at 1080x1920, writes `media/vertical/` (thumbnail, screenshots, demo, preview, GIF), and sets `media_vertical`. `arcade publish` does this for you when the set is missing. Re-run it after a new landscape demo or thumbnail; the dry run warns when the vertical set is older.
158
+
159
+ Look at the vertical files too (a contact sheet: `ffmpeg -i media/vertical/demo.mp4 -vf fps=1/3,scale=180:-1,tile=6x2 -frames:v 1 ../rec/vsheet.png`). Upright footage of a game that only plays sideways, or a blurred fill whose middle band is unreadable, is a false promise.
160
+
123
161
  ## The dry run
124
162
 
125
163
  Run `arcade publish --dry-run` and read all of it. It uploads nothing, but know what it is:
126
164
 
127
165
  - Signed out, it runs only the local checks (arcade.json, file types, license, media, secrets), prints any Heads up, and exits 1 asking you to sign in. The full dry run needs `arcade login` and a network connection, because the arcade checks the upload before anything is listed. Problems the arcade finds (a file over 25 MB, too many files, a slug that's taken, a stale base) exit before the file list prints.
128
166
  - Every problem it exits 2 on says what to produce: a missing thumbnail names the size and path to capture, a missing arcade.json lists the fields to write.
129
- - It may make `media/preview.mp4` and `media/preview.gif` and write `preview_video` and `preview_gif` into arcade.json.
167
+ - It may make `media/preview.mp4` and `media/preview.gif` and write `preview_video` and `preview_gif` into arcade.json, and, for a landscape game, the vertical set in `media/vertical/` and `media_vertical`.
130
168
  - It lists the first 40 files, then "…and N more", but files inside hidden folders (like `.claude/`) are always listed. New files are tagged `new`. Review the whole tree yourself: `find . -type f -not -path './node_modules/*' -not -path './.git/*'`.
131
169
  - It follows symlinks only when they point inside the folder. Anything else is listed as skipped, and never uploaded.
132
170
  - It says "Published games are open source under MIT" under the action line. Anything bundled from others keeps its own license and must be the user's to share.
133
- - Its **Heads up** list flags likely mistakes without stopping the publish: a leftover `parents/`, `BLEND.md` or `PROMPT.md`, media already on the arcade on a fork, blend, or regen (so probably the parent's), starter text, a placeholder slug, a home-folder path in the prompt or notes, a thumbnail over 500 KB, and no model or prompt on the Model card. Fix each one, or tell the user why it's fine.
171
+ - Its **Heads up** list flags likely mistakes without stopping the publish: a leftover `parents/`, `BLEND.md` or `PROMPT.md`, media already on the arcade on a fork, blend, or regen (so probably the parent's), starter text, a placeholder slug, a home-folder path in the prompt or notes, a thumbnail over 500 KB, no vertical thumbnail, a vertical set older than the landscape media, game code that calls `requestFullscreen` ("the arcade's player owns fullscreen; remove your fullscreen button"), and no model or prompt on the Model card. Fix each one, or tell the user why it's fine.
134
172
  - Its Model card shows the model, tokens, cost, time, process, and the prompt's length, not the prompt or notes text. Read those in arcade.json.
135
173
 
136
174
  What to check: