// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or // .storebound/credentials.json, which must stay gitignored). Paths are relative to this file. // The type import is optional: it only works when storebound is installed in the project (bun add -d ). import type { ShipConfig } from 'storebound'; const config: ShipConfig = { // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is // already live: storebound onboard --declarations), answer the rest, then remove the area from this list. unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'], app: { name: 'Example Game', profile: 'game', copyright: '2026 Example Studio', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com', marketingUrl: 'https://example.com', // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads website: 'https://example.com', contactEmail: 'support@example.com', }, // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale languages: [ { id: 'en', play: 'en-US', apple: 'en-US' }, { id: 'tr', play: 'tr-TR', apple: 'tr' }, { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' }, { id: 'ja', play: 'ja-JP', apple: 'ja' }, { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true }, ], play: { packageName: 'com.example.game', defaultLanguage: 'en-US', listing: 'store/listing.md', icon: 'store/icons/play-512.png', // read by `storebound scan` (fill in what is true for your account and app): // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy }, apple: { bundleId: 'com.example.game', // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there ("already being used"); default app.name // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out listing: 'store/appstore.md', reviewShots: 'store/iap-review', // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption. // usesNonExemptEncryption: false, // ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded // iphoneMedium: true, // optional 6.3" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5" set down // releaseType: "afterApproval", // afterApproval | manual | scheduled (+ earliestReleaseDate): how an approved version goes live; unset = new versions MANUAL // iphoneDuo: true, // optional iPhone Duo (foldable) sets, outer 1398 x 2034 + inner 2007 x 2853; rendered and checked, not uploaded until the API takes them // screenshotTypes: ['APP_IPHONE_61'], // what `storebound validate` requires per locale (default: medium display, met by a 6.9" or 6.5" set; + APP_IPAD_PRO_3GEN_129 with ipad) // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only. testflight: { groups: [ { name: 'Team', internal: true, allBuilds: true }, { name: 'Friends' }, ], description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' }, }, info: { categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' }, ageRating: { advertising: true }, // everything else NONE / false contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT', free: true, reviewNotes: [ 'Single-player game. No account or login is needed; there is no user-generated content or chat.', 'In-app purchases: Home > Shop. Restore Purchases is in Settings.', 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.', 'Privacy policy: Settings > Privacy.', // With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add: // 'Shop offers and prices shown come from and change remotely; no feature is switched on or off after review.', // Google Play has no review-notes field: put the same line in reviewAccess.instructions (Play App content > // App access, 'Any other information') when the app has a sign-in, and say in the full description what needs // a paid subscription (Play: Deceptive behavior, Payments). ].join('\n\n'), }, }, // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry. // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' }, // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a // region by hand (Apple needs an existing price point with exactly that value). products: [ { id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99, overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }], listings: { en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' }, tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' }, }, reviewScreenshot: 'store/iap-review/shop.png', }, { id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99, listings: { en: { title: '500 Coins', description: 'A small bag of coins.' }, tr: { title: '500 Altın', description: 'Küçük bir kese altın.' }, }, reviewScreenshot: 'store/iap-review/shop.png', }, ], // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications, // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation: dataSafety: { collectsData: true, encryptedInTransit: true, // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's // "Username and other authentication"; `storebound scan` proposes it). With accounts Play also requires the // delete-account URL (from accounts.deletionUrl). That page also answers "can users request that their data is // deleted?" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true + // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's // current form (cli/data/play-data-safety-form.csv). accountCreation: ['PSL_ACM_NONE'], data: { PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] }, PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] }, PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] }, }, }, // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation // dashboard's app-ads.txt page). Placeholder line below; replace it. // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] }, renders: 'store/renders', shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', // /phone/.png, /tablet/.png out: 'store/renders', logo: 'store/shots/logo.png', // optional: hero scene + feature graphic // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails). fonts: { title: 'Paytone One', body: 'Montserrat' }, // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar) // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13" iPad below its 24 px bar), else 800 x 1240 scenes: [ { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }], badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] }, { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }], badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] }, { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }], badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] }, { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }], badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] }, ], feature: { frame: 'hero.png', palette: 'fire' }, capture: { baseUrl: 'http://127.0.0.1:5173', query: 'lang={lang}&noads=1&seed=1', shots: { 'hero': { path: '/?level=12', waitFor: "window.game && game.state === 'playing'", delayMs: 1500, device: 'both' }, 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' }, 'level-a': { path: '/?level=20', delayMs: 2000 }, 'level-b': { path: '/?level=40', delayMs: 2000 }, 'level-c': { path: '/?level=60', delayMs: 2000 }, 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' }, }, }, }, icons: { composite: 'store/icon/icon.png', // 1024 opaque foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent background: 'store/icon/background.png', // 1024 opaque out: 'store/icons', web: 'public/icons', // capacitorAssets: 'native/assets', // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets', // androidRes: 'native/android/app/src/main/res', }, }; export default config;