Skip to content

Dark Cosmos — App Store Launch Assets Implementation Plan

Dark Cosmos — App Store Launch Assets Implementation Plan

Section titled “Dark Cosmos — App Store Launch Assets Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Produce a fully polished public App Store listing for Dark Cosmos v0.1 across iPhone, iPad, Apple TV, and Mac — screenshots, trailer, copy, icon, a refocused site, and a privacy page — ready to submit.

Architecture: Render the game on the Mac forced onto the Metal renderer (so the premium HDR/effects look is on), stage 6 scenes via the existing dev-console seams, capture viewport PNGs at every device aspect ratio, composite them through one branded HTML template at exact device pixels, cut a trailer (with original code-synthesised music) via Godot Movie Maker + ffmpeg, and refocus the site/privacy page in the separate bullet-heaven-site repo. All capture tooling lives in marketing/, excluded from game exports, never referenced by the test suite, so determinism is untouched.

Tech Stack: Godot 4.6.3 (gl_compatibility game; capture run with --rendering-method mobile), Python 3 (numpy for music synthesis, PIL where useful), headless Chrome (compositor render), ffmpeg (trailer), plain HTML/CSS (site + compositor), CF Pages + R2 (site hosting).

  • Veracity: depict only what the shipped build contains — twin-stick, lots of upgrades, drone army, big bosses, elemental reactions (as a mechanic). No story mode, no co-op, no explorable areas. Never claim “14 elements” or “91 reactions.” Real: 6 weapon elements / ~11 seen / ~10 named reactions.
  • Drop the “Crystals” label in all player-facing copy and on the site. It is just Dark Cosmos.
  • Copy style (all player-facing text — listing, site, privacy): no em-dashes (—); plain English, short sentences; no AI-marker words (“delve”, “seamless”, “unlock”, “elevate”, “harness”, “robust”, etc.).
  • Apple character limits: name ≤ 30, subtitle ≤ 30, promotional text ≤ 170, keywords ≤ 100 (comma-separated).
  • Exact screenshot resolutions (landscape): iPhone 2796×1290, iPad 2732×2048, Apple TV 1920×1080, Mac 2880×1800. Confirm against App Store Connect before mass-render.
  • Determinism / purity: marketing/ is excluded from every export preset’s exclude_filter and is never imported by tests/. The capture harness is render-side only and uses existing Sim dev seams (dev_*) — it adds no /sim logic. Run the full suite + determinism test unchanged after any repo change.
  • Name locked: Dark Cosmos. Bundle id stays digital.lumara.bulletheaven (unchanged — only the display name changes).

bullet-heaven/ (game repo)
marketing/
capture/
shoot.gd # capture harness: boot Main, force scenes, save PNGs
shoot.tscn # scene the harness runs in
scenes.gd # the 6 scene staging recipes (dev-seam scripts)
audio/
compose.py # numpy synth -> dark_cosmos_theme.wav
dark_cosmos_theme.wav # generated, committed
template/
screenshot.html # branded compositor template (data-driven)
render.mjs # headless-chrome renderer: frame+caption -> exact-size PNG
scenes.json # caption text per scene + per-family safe-area config
raw/ # raw viewport PNGs + trailer master (gitignored if large)
trailer/
build_trailer.sh # ffmpeg edit: master -> 3 device cuts
appstore/
copy.md # final listing copy (char-counted)
compliance.md # App Privacy + age-rating answers
iphone/ ipad/ tv/ mac/ # final composited screenshots
previews/ # final trailer cuts
icon/ # icon (+ any re-cut)
press/ # one-sheet + hero feature graphic
bullet-heaven-site/ (separate repo)
index.html # rebrand -> Dark Cosmos, refocus content
privacy/index.html # new privacy page

Files:

  • Create: marketing/appstore/copy.md

Interfaces:

  • Produces: the canonical copy strings reused by the site (Task 8) and ASC submission (Task 14).

  • Step 1: Write the copy file with these exact strings (no em-dashes, within limits):

# Dark Cosmos — App Store copy
Name: Dark Cosmos
Subtitle: Twin-stick survival roguelite
Promotional text:
Two sticks, an auto-firing arsenal, and a drone army you build as you go. Stack elemental reactions, grab upgrades, and survive the neon dark against huge bosses.
Keywords:
survival,roguelite,twin stick,neon,drone,shooter,horde,arcade,action,bullet heaven,upgrade,boss
Description:
Drop into the neon dark and survive. Dark Cosmos is a twin-stick survival roguelite: move with one stick, aim your main weapon with the other, and let the rest of your arsenal fire itself.
Every level you pick an upgrade. Flat stat boosts, transformative mods that change how your weapons behave, and evolutions that turn a weapon into something new. No two runs build the same way.
Build a drone army from the Drone Bay and send it into the swarm beside you. Every weapon carries an element, so hit an enemy, then hit it again with a different one, and set off a reaction. Push deep enough and the bosses come for you.
It is fast, it is bright, and it holds a steady frame rate when the screen fills with hundreds of enemies. See how far one run can take you.
What's New (v0.1):
First release. Drop into the neon dark, build your arsenal and your drones, set off elemental reactions, and see how far one run can take you.
Category: Games (primary: Action, secondary: Arcade)
Age rating: expected 9+ (abstract neon violence, no gore)
  • Step 2: Verify character limits

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven
python3 - <<'PY'
import re
t=open('marketing/appstore/copy.md').read()
def field(name):
m=re.search(rf'^{name}:\s*\n?(.*?)(?:\n\n|\nKeywords:|\nDescription:|\nWhat|\nCategory:)', t, re.S|re.M)
return ' '.join(m.group(1).split()) if m else ''
for n,lim in [('Subtitle',30),('Promotional text',170),('Keywords',100)]:
v=field(n); print(f"{n}: {len(v)}/{lim} {'OK' if len(v)<=lim else 'OVER'} -> {v[:60]}")
PY

Expected: Subtitle ≤30, Promotional text ≤170, Keywords ≤100, all OK. If any is OVER, trim and re-run.

  • Step 3: Em-dash + AI-marker scan

Run:

Terminal window
grep -nE '—|delve|seamless|unlock|elevate|harness|robust|tapestry|leverage' marketing/appstore/copy.md && echo "FOUND - fix" || echo "clean"

Expected: clean.

  • Step 4: Commit
Terminal window
git add marketing/appstore/copy.md
git commit -m "docs(marketing): final App Store listing copy"

Task 2: Compliance answers (App Privacy + age rating)

Section titled “Task 2: Compliance answers (App Privacy + age rating)”

Files:

  • Create: marketing/appstore/compliance.md
  • Read first: net/telemetry.gd, net/gameplay_telemetry.gd, net/telemetry_device.gd (confirm exactly what is sent)

Interfaces:

  • Produces: the answers Chris enters in ASC’s App Privacy + Age Rating questionnaires; must match the privacy page (Task 7).

  • Step 1: Read the telemetry senders to confirm fields actually transmitted.

Run: sed -n '1,80p' net/telemetry.gd net/gameplay_telemetry.gd net/telemetry_device.gd Confirm: perf sample (fps, frame/sim/render ms, draws, counts, build, session, coarse device id) + gameplay summary (run_time, level, kills, gold, dps, mode). No name/email/account/location/IDFA.

  • Step 2: Write compliance.md:
# Dark Cosmos — ASC compliance answers
## App Privacy (Data Collection)
Does the app collect data? YES (anonymous, optional analytics).
Data types collected:
- Diagnostics > Performance Data (frame rate, timings, draw counts, app build)
- Usage Data > Product Interaction (run length, level reached, kills, mode)
For BOTH types:
- Linked to the user's identity? NO
- Used for tracking? NO
- Used for: App Functionality + Analytics
Identifiers: NONE collected (no IDFA, no account, no device advertising id).
A random per-run session id and coarse device model (e.g. "AppleTV11,1") are
sent for diagnostics only; not tied to identity.
## Age Rating (answer NONE/INFREQUENT unless noted)
- Cartoon or Fantasy Violence: INFREQUENT/MILD (abstract neon shapes, no blood/gore)
- Realistic Violence: NONE
- Sexual content / nudity / profanity / drugs / gambling / horror: NONE
- Unrestricted web access: NO
Expected result: 9+.
## Export compliance
Uses non-exempt encryption? NO (ITSAppUsesNonExemptEncryption = false).
## URLs
Support URL: https://dark-cosmos.lumara.digital (or current site URL)
Marketing URL: same
Privacy Policy URL: <site>/privacy
  • Step 3: Commit
Terminal window
git add marketing/appstore/compliance.md
git commit -m "docs(marketing): ASC App Privacy + age-rating answers"

Files:

  • Create: marketing/audio/compose.py, marketing/audio/dark_cosmos_theme.wav

Interfaces:

  • Produces: dark_cosmos_theme.wav (~28s, 44.1kHz stereo) consumed by the trailer (Task 12).

  • Step 1: Write compose.py — a numpy synth producing a dark synthwave bed: a minor-key arpeggio, a sub bass on the root, a soft pad, and a four-on-the-floor kick + offbeat hat. Use simple oscillators (saw via 2*(t*f%1)-1, sine), ADSR envelopes, a gentle lowpass (one-pole), soft-clip limiter, and a fade in/out. Tempo ~110 BPM, key A minor, ~28s. Write a stereo 16-bit WAV with wave + numpy.

(Concrete skeleton — fill the note tables and mix levels, keep it dependency-light: numpy + stdlib wave only.)

import numpy as np, wave
SR=44100; BPM=110; BEAT=60/BPM; BARS=14; T=BARS*4*BEAT
def env(n,a,d,s,r): # samples a/d/r, sustain level s
e=np.ones(n); ai=int(a*SR);di=int(d*SR);ri=int(r*SR)
if ai:e[:ai]=np.linspace(0,1,ai)
if di:e[ai:ai+di]=np.linspace(1,s,di)
e[ai+di:n-ri]=s
if ri:e[n-ri:]=np.linspace(s,0,ri)
return e
def saw(f,n,ph=0): t=np.arange(n)/SR; return 2*((t*f+ph)%1)-1
def sine(f,n): t=np.arange(n)/SR; return np.sin(2*np.pi*f*t)
def note(midi): return 440*2**((midi-69)/12)
mix=np.zeros(int(T*SR))
# ... lay kick every beat, hat offbeats, sub bass on bar root, A-minor arp (A C E G) 1/8 notes,
# pad chord per 2 bars; sum with levels ~ kick .9 bass .5 arp .35 pad .25 hat .15
# one-pole lowpass on arp+pad for warmth; tanh soft-clip the master; 0.5s fade in, 2s fade out
master=np.tanh(mix*1.2); master/=np.max(np.abs(master))+1e-9
st=np.stack([master,master],1) # (widen arp/pad L/R for stereo if desired)
pcm=(st*0.95*32767).astype('<i2')
with wave.open('marketing/audio/dark_cosmos_theme.wav','wb') as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes(pcm.tobytes())
print('wrote', T, 's')
  • Step 2: Generate + validate the WAV

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven && python3 marketing/audio/compose.py
python3 - <<'PY'
import wave; w=wave.open('marketing/audio/dark_cosmos_theme.wav'); print('ch',w.getnchannels(),'rate',w.getframerate(),'sec',round(w.getnframes()/w.getframerate(),1))
PY

Expected: ch 2 rate 44100 sec ~28.

  • Step 3: Listen + iterateopen marketing/audio/dark_cosmos_theme.wav (or afplay). Tune levels/notes until it reads as a confident dark-cosmos electronic bed. Get Chris’s nod on the feel before the trailer bakes it in.

  • Step 4: Commit

Terminal window
git add marketing/audio/compose.py marketing/audio/dark_cosmos_theme.wav
git commit -m "feat(marketing): original electronic trailer theme (numpy synth)"

Task 4: Capture harness (Metal render + viewport PNG)

Section titled “Task 4: Capture harness (Metal render + viewport PNG)”

Files:

  • Create: marketing/capture/shoot.gd, marketing/capture/shoot.tscn
  • Read first: main.gd (find: the start-menu run-start entry for the crystals ruleset; the debug-UI nodes to hide; how QualityManager is pinned; apply_dev_command)
  • Modify: export_presets.cfg (both repos) — add marketing/* to each exclude_filter

Interfaces:

  • Produces: marketing/capture/shoot.gd with capture_frame(path: String, size: Vector2i) -> void and a _run_all() driver; raw PNGs in marketing/raw/.

  • Step 1: Exclude marketing from exports — edit each preset’s exclude_filter in bullet-heaven/export_presets.cfg (Web, macOS) and bullet-heaven-tvos/export_presets.cfg (tvOS, iOS) to include marketing/*. The tvOS/iOS presets already exclude tests/*, telemetry/*, tools/*; append , marketing/*. Verify nothing in marketing/ ships.

  • Step 2: Write shoot.tscn + shoot.gd — a Node that instances Main, configures it for clean capture, and exposes capture. Skeleton (wire the marked integration points against main.gd):

extends Node
# Run with: godot --path . --rendering-method mobile res://marketing/capture/shoot.tscn
const SIZES := {"iphone":Vector2i(2796,1290),"ipad":Vector2i(2732,2048),"tv":Vector2i(1920,1080),"mac":Vector2i(2880,1800)}
var main
func _ready() -> void:
assert(RenderingServer.get_rendering_device() != null, "run with --rendering-method mobile for HDR/Metal effects")
main = preload("res://main.tscn").instantiate()
add_child(main)
await get_tree().process_frame
# INTEGRATION (read main.gd): start a crystals run, enable attract AutoPlayer,
# pin QualityManager L0, hide debug UI (F2 overlay, build label, dev code).
Scenes.prepare(main) # see Task 5
await _run_all()
get_tree().quit()
func capture_frame(path: String, size: Vector2i) -> void:
get_window().size = size
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw # 2 frames: let stretch settle
var img := get_viewport().get_texture().get_image()
DirAccess.make_dir_recursive_absolute("res://marketing/raw".replace("res://", ProjectSettings.globalize_path("res://")))
img.save_png("marketing/raw/%s" % path)
func _run_all() -> void:
for scene in Scenes.LIST: # see Task 5
await Scenes.stage(main, scene) # set up the scenario deterministically
for fam in SIZES:
await capture_frame("%s_%s.png" % [scene, fam], SIZES[fam])
  • Step 3: Smoke-capture one frame at iPhone size to prove the pipeline (temporarily stage just a running crystals scene):

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven
/opt/homebrew/bin/godot --path . --rendering-method mobile res://marketing/capture/shoot.tscn 2>&1 | grep -iE "error|script error" || true
python3 - <<'PY'
from PIL import Image; im=Image.open('marketing/raw/horde_iphone.png'); print(im.size, 'mean', sum(im.convert('L').resize((1,1)).getpixel((0,0)) for _ in [0])/1)
PY

Expected: image is (2796, 1290) and not near-black (mean brightness > 10 — proves HDR neon rendered). If near-black, the run did not start / renderer wrong — fix integration before proceeding.

  • Step 4: Commit
Terminal window
git add marketing/capture export_presets.cfg
git commit -m "feat(marketing): Metal capture harness + exclude marketing from exports"

Task 5: Scene staging recipes (the 6 money shots)

Section titled “Task 5: Scene staging recipes (the 6 money shots)”

Files:

  • Create: marketing/capture/scenes.gd
  • Read first: the dev seams in sim/sim.gd (dev_spawn_custom, dev_spawn_boss path, dev_clear_enemies, dev_suppress_spawns, dev_invuln) and main.apply_dev_command; the Drone Bay open path; the level-up panel trigger; the codex card trigger.

Interfaces:

  • Consumes: main (the live game), Sim dev seams.

  • Produces: Scenes.LIST: Array[String], Scenes.prepare(main), Scenes.stage(main, name) -> awaitable that arranges each scenario and waits for it to look right.

  • Step 1: Write scenes.gd staging each of the 6 scenes via dev seams (invuln on so the auto-player survives the staged moment; pause spawns then place a controlled set). Each stage() arranges the world, advances enough ticks for effects to bloom, and returns.

class_name Scenes
extends RefCounted
const LIST := ["horde","twinstick","reaction","upgrades","drones","boss"]
static func prepare(main) -> void:
main.apply_dev_command({"cmd":"invuln","on":true})
# hide debug UI + pin quality L0 here (read main.gd for the exact nodes/flags)
static func stage(main, name) -> void:
var sim = main.sim
match name:
"horde": # dense swarm around the player, mid-fight
main.apply_dev_command({"cmd":"pause_spawns","on":true})
for i in 140: sim.dev_spawn_custom(_swarm_spec(), _ring_pos(i))
"twinstick": # aim a direction so the aimed weapon + auto weapons all fire into a pack
for i in 60: sim.dev_spawn_custom(_swarm_spec(), _ring_pos(i))
main.apply_dev_command({"cmd":"aim","dir":[1,0]}) # if supported; else set auto-player aim
"reaction": # force a Plasma burst: lightning + fire on a cluster
# spawn a tight cluster, let pulse(lightning)+nova(fire) react; wait for the burst frame
for i in 40: sim.dev_spawn_custom(_swarm_spec(), _ring_pos(i, 220))
"upgrades": # open the level-up card screen (grant enough XP to trigger)
main.apply_dev_command({"cmd":"player_stat","field":"xp","value":9999}) # or call the run's level-up directly
"drones": # open the Drone Bay overlay with a configured loadout
main.open_bay_for_capture() # add a tiny capture-only helper if no direct call exists
"boss": # spawn the Graviton, let the gravity lens engage
main.apply_dev_command({"cmd":"spawn_boss","which":"graviton"})
# advance ~30 frames so bloom/lens/particles reach a good-looking moment
for _f in 30: await main.get_tree().process_frame

(The _swarm_spec(), _ring_pos() helpers and the exact dev-command keys come from reading the console caps in sim.gd/main.gd. Where a direct call is cleaner than a dev command, call the sim method.)

  • Step 2: Capture all 24 raw frames

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven
/opt/homebrew/bin/godot --path . --rendering-method mobile res://marketing/capture/shoot.tscn 2>&1 | grep -iE "script error" || true
ls marketing/raw/*.png | wc -l
python3 - <<'PY'
from PIL import Image; import glob
for f in sorted(glob.glob('marketing/raw/*.png')):
im=Image.open(f); print(f.split('/')[-1], im.size)
PY

Expected: 24 PNGs; each at its family’s exact size; none near-black. Eyeball each for composition; restage any weak scene (use the reserve list from the spec) and re-run.

  • Step 3: Commit
Terminal window
git add marketing/capture/scenes.gd
git commit -m "feat(marketing): 6-scene capture staging via dev seams"

Files:

  • Create: marketing/template/screenshot.html, marketing/template/render.mjs, marketing/template/scenes.json

Interfaces:

  • Consumes: marketing/raw/<scene>_<fam>.png, captions from scenes.json.

  • Produces: marketing/appstore/<fam>/NN_<scene>.png at exact device size.

  • Step 1: Write scenes.json — caption + sizes:

{
"families": {"iphone":[2796,1290],"ipad":[2732,2048],"tv":[1920,1080],"mac":[2880,1800]},
"safe": {"tv":90,"iphone":48,"ipad":40,"mac":40},
"scenes": [
{"id":"horde","caption":"Survive the neon dark"},
{"id":"twinstick","caption":"Two sticks. The rest fires itself."},
{"id":"reaction","caption":"Set off elemental reactions"},
{"id":"upgrades","caption":"An upgrade every level"},
{"id":"drones","caption":"Build your drone army"},
{"id":"boss","caption":"Fight huge bosses"}
]
}
  • Step 2: Write screenshot.html — a full-bleed gameplay <img> with a bottom caption band (dark-cosmos gradient, Orbitron-style heavy heading, small “DARK COSMOS” wordmark), reading ?frame=&caption=&w=&h=&safe= query params and sizing body to exactly w×h. Captions sit inside the safe inset so TV overscan / iPhone chrome never clip them.

  • Step 3: Write render.mjs — headless Chrome (reuse the chrome-devtools/Playwright MCP or puppeteer if available; else a chrome --headless --screenshot --window-size=WxH call) that, for each scene × family, loads screenshot.html with the right params at deviceScaleFactor=1 and saves the exact-size PNG to marketing/appstore/<fam>/.

  • Step 4: Render one test image (iPhone, horde) and verify exact pixels:

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven/marketing/template && node render.mjs --only iphone:horde
python3 - <<'PY'
from PIL import Image; im=Image.open('../appstore/iphone/01_horde.png'); print(im.size); assert im.size==(2796,1290)
PY

Expected: (2796, 1290), caption legible, no clipping.

  • Step 5: Render all 24 + verify

Run:

Terminal window
node render.mjs --all
python3 - <<'PY'
from PIL import Image; import glob
for f in sorted(glob.glob('../appstore/*/*.png')):
print(f.split('marketing/')[-1], Image.open(f).size)
print('count', len(glob.glob('../appstore/*/*.png')))
PY

Expected: 24 images at correct sizes.

  • Step 6: Commit
Terminal window
cd /Users/chris/Claude/bullet-heaven
git add marketing/template marketing/appstore/iphone marketing/appstore/ipad marketing/appstore/tv marketing/appstore/mac
git commit -m "feat(marketing): branded screenshot compositor + 24 final screenshots"

Files:

  • Modify: bullet-heaven-site/index.html
  • Create: bullet-heaven-site/privacy/index.html
  • Read first: bullet-heaven-site/CLAUDE.md (deploy steps, R2 demo, clean-staging-dir rule)

Interfaces:

  • Produces: the live privacy URL used in compliance.md (Task 2) and ASC.

  • Step 1: Rebrand index.html — replace “Bullet Heaven” with “Dark Cosmos” in <title>, <h1>, meta description, and body; drop any “Crystals” wording; refocus the hero + features on the verified pitch (twin-stick, lots of upgrades, drone army, big bosses, elemental reactions as a mechanic). Remove or correct the “14 elements / 91 reactions” line and the changelog claims that overstate the shipped build. Keep the playable web-demo embed. No em-dashes, no AI-marker words.

  • Step 2: Write privacy/index.html — short, plain privacy page styled to match the site. Content (paraphrase, no em-dashes):

Dark Cosmos collects a small amount of anonymous data, only to improve performance and to balance the game. We record things like frame rate and timings, your device model, an app build number, a random per-session id, and a summary of a run (how long it lasted, level reached, mode). We do not collect your name, email, account, contacts, or location. We do not show ads, we do not track you across apps, and we never sell data. Questions: chris@lumara.digital.

  • Step 3: Local check

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven-site
grep -niE 'bullet heaven|crystals|—|91 reactions|14 elements' index.html privacy/index.html || echo "clean"

Expected: clean (or only intentional genre-keyword uses you accept).

  • Step 4: Deploy per bullet-heaven-site/CLAUDE.md (CF Pages, clean-staging-dir — do NOT upload internal files), then verify live:

Run:

Terminal window
curl -sI https://<live-site>/privacy/ | head -1 # expect 200
curl -s https://<live-site>/ | grep -io 'Dark Cosmos' | head -1

Expected: privacy 200; homepage shows “Dark Cosmos”.

  • Step 5: Commit (site repo)
Terminal window
cd /Users/chris/Claude/bullet-heaven-site
git add index.html privacy/index.html
git commit -m "feat: rebrand to Dark Cosmos + add privacy page"

Files:

  • Create: marketing/trailer/build_trailer.sh
  • Consumes: marketing/audio/dark_cosmos_theme.wav (Task 3), a Movie Maker master capture

Interfaces:

  • Produces: marketing/appstore/previews/{iphone,ipad,tv}.mp4 meeting Apple App Preview specs.

  • Step 1: Capture the master — run an attract-mode session on the Metal renderer in Movie Maker mode at high resolution:

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven
/opt/homebrew/bin/godot --path . --rendering-method mobile --fixed-fps 60 \
--write-movie marketing/raw/trailer_master.avi res://main.tscn &
# let it auto-play a juicy ~75s run, then quit the window

(Ensure attract/auto mode + L0 quality + debug UI hidden, same as capture; a dedicated shoot.tscn movie variant is fine.)

  • Step 2: Write build_trailer.sh — ffmpeg pipeline: trim to the best ~22s, prepend a 1.5s Dark Cosmos title card, overlay 3-4 caption beats matching the screenshot captions, mix in dark_cosmos_theme.wav, fade in/out, then export per family (H.264, yuv420p, 30fps): iPhone 1920×886, iPad 1920×1440 (or 2732×2048-derived per ASC accepted preview size), Apple TV 1920×1080. Confirm exact accepted preview dimensions in ASC before final export.

  • Step 3: Build + verify

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven && bash marketing/trailer/build_trailer.sh
for f in marketing/appstore/previews/*.mp4; do ffprobe -v error -show_entries stream=width,height,codec_name,duration -of csv "$f"; done

Expected: 3 mp4s, H.264, ~22s, correct per-family dimensions. Watch each end-to-end.

  • Step 4: Commit
Terminal window
git add marketing/trailer marketing/appstore/previews
git commit -m "feat(marketing): app preview trailer (3 device cuts) with original music"

Files:

  • Read: bullet-heaven-tvos/icon.png (1024²)

  • Create (if re-cut): marketing/appstore/icon/icon.png

  • Step 1: Downscale-preview the icon to home-screen sizes and inspect:

Run:

Terminal window
cd /Users/chris/Claude/bullet-heaven
python3 - <<'PY'
from PIL import Image
im=Image.open('../bullet-heaven-tvos/icon.png').convert('RGB')
for s in (180,120,80,60): im.resize((s,s)).save(f'/tmp/icon_{s}.png')
print('wrote /tmp/icon_{180,120,80,60}.png')
PY
open /tmp/icon_60.png /tmp/icon_120.png

Expected: the mark still reads at 60px (clear silhouette, not muddy).

  • Step 2: If it holds up, no change — note “icon OK” in marketing/appstore/icon/NOTES.md. If muddy at small size, re-cut a higher-contrast 1024² into marketing/appstore/icon/icon.png and (later) swap it into the export presets. Commit either way:
Terminal window
git add marketing/appstore/icon
git commit -m "chore(marketing): icon legibility check"

Task 10: Press one-sheet + hero feature graphic

Section titled “Task 10: Press one-sheet + hero feature graphic”

Files:

  • Create: marketing/appstore/press/one-sheet.html (→ PDF/PNG), marketing/appstore/press/hero.png

  • Step 1: Build a one-sheet HTML (title, one-line pitch, 4 feature bullets from the verified pitch, 3 screenshots, links, contact) and render to PNG/PDF via the same headless-chrome path as Task 6. Build a wide hero key-art frame from the best horde/boss capture + the wordmark.

  • Step 2: Verify + commit

Run: ls -la marketing/appstore/press/

Terminal window
git add marketing/appstore/press
git commit -m "feat(marketing): press one-sheet + hero feature graphic"

Files:

  • Create: marketing/appstore/SUBMIT.md

  • Step 1: Write a checklist Chris follows in App Store Connect: set display name to Dark Cosmos; paste copy from copy.md; upload screenshots per family from marketing/appstore/<fam>/; upload the 3 previews; set category Action/Arcade; complete App Privacy + Age Rating from compliance.md; set support/marketing/privacy URLs; confirm build (iOS + tvOS + Mac) attached; export-compliance = no non-exempt encryption; submit for review. Note the realistic timeline: review 1-3 days, live on approval.

  • Step 2: Commit

Terminal window
git add marketing/appstore/SUBMIT.md
git commit -m "docs(marketing): App Store Connect submission checklist"

  • Spec coverage: capture harness (T4), screenshots 6×4 (T5–T6), copy (T1), trailer + music (T3, T8), icon (T9), extras (T10), site refocus + privacy (T7), compliance (T2), submission (T11) — every spec §2 deliverable + §10 compliance item has a task.
  • Placeholders: engine-dependent integration points (run-start, dev-command keys, debug-UI nodes) are explicit “read main.gd/sim.gd and wire” steps with concrete skeletons + verification, not vague TODOs. Exact resolutions/limits/commands are inline.
  • Type consistency: capture_frame(path,size), Scenes.LIST/prepare/stage, family keys iphone/ipad/tv/mac, and resolution tuples are identical across T4–T6 and scenes.json.

Independent streams that can run concurrently: T1+T2 (copy/compliance), T3 (music), T7 (site/privacy), and T4 (harness) have no cross-dependencies. T5 needs T4; T6 needs T5; T8 needs T3+T4; T10 needs T6. Front-load T1/T2/T7 (submission-blockers) and T4 (the visual unlock).