evolutionary-arcade 0.0.1 → 0.1.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/package.json CHANGED
@@ -1,12 +1,51 @@
1
1
  {
2
2
  "name": "evolutionary-arcade",
3
- "version": "0.0.1",
4
- "description": "Evolutionary Arcade creator CLI. Coming soon.",
3
+ "version": "0.1.0",
4
+ "description": "The arcade CLI for Evolutionary Arcade. Publish, update, regen, fork, and blend AI-made browser games with your own coding agent.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Evolutionary Arcade <hello@evolutionaryarcade.com> (https://evolutionaryarcade.com)",
8
+ "bin": {
9
+ "arcade": "dist/cli.js"
10
+ },
5
11
  "files": [
6
- "README.md"
12
+ "dist",
13
+ "skills",
14
+ "README.md",
15
+ "LICENSE"
7
16
  ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "homepage": "https://evolutionaryarcade.com",
21
+ "bugs": {
22
+ "url": "https://github.com/DamDam98/evolutionary-arcade/issues",
23
+ "email": "hello@evolutionaryarcade.com"
24
+ },
8
25
  "repository": {
9
26
  "type": "git",
10
- "url": "git+https://github.com/DamDam98/evolutionary-arcade.git"
27
+ "url": "git+https://github.com/DamDam98/evolutionary-arcade.git",
28
+ "directory": "packages/cli"
29
+ },
30
+ "keywords": [
31
+ "games",
32
+ "arcade",
33
+ "ai",
34
+ "agents",
35
+ "cli",
36
+ "threejs"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "typecheck": "tsc -p tsconfig.json",
41
+ "test": "vitest run"
42
+ },
43
+ "dependencies": {
44
+ "commander": "15.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@ea/format": "0.0.0",
48
+ "@types/node": "26.6.3",
49
+ "tsup": "8.5.1"
11
50
  }
12
51
  }
@@ -0,0 +1,450 @@
1
+ ---
2
+ name: arcade-building-games
3
+ description: Use when building or improving a browser game for Evolutionary Arcade (evolutionaryarcade.com). That covers starting one with `arcade new`, making a fork, blend, or regen playable, tuning feel, designing progression that keeps players coming back for weeks, adding gamepad or touch support, saving progress to the player's profile, and checking that the game runs in the arcade's sandboxed iframe and CSP before `arcade publish`. It separates what the arcade enforces (publish fails or the game breaks) from house rules, says what makes a game feel great, and shows how to verify it by actually playing it.
4
+ ---
5
+
6
+ # Building arcade games
7
+
8
+ ## The situation
9
+
10
+ Your game runs in an iframe on evolutionaryarcade.com. It's served from
11
+ `https://<slug>.evolutionaryarcade.games/_g/<generationId>/...` in this frame:
12
+
13
+ ```html
14
+ <iframe sandbox="allow-scripts allow-same-origin allow-pointer-lock"
15
+ allow="fullscreen; gamepad; autoplay" allowfullscreen>
16
+ ```
17
+
18
+ The games host sends a CSP that allows inline scripts, `eval`, WebAssembly, and workers. It blocks
19
+ every outside connection (`connect-src 'self'`) and all forms. Inside the frame, `alert`,
20
+ `confirm`, and `prompt` do nothing. Popups, `target="_blank"` links, downloads, top-level
21
+ navigation, and `screen.orientation.lock()` all fail. The camera, microphone, and geolocation are
22
+ off. The page isn't cross-origin isolated, so there's no `SharedArrayBuffer`, which rules out
23
+ threaded WASM builds. Draw your dialogs inside the game.
24
+
25
+ The player is a 16:9 box across the game page, with the arcade's own fullscreen and restart
26
+ buttons. Restart reloads the frame, so a fresh load must always come up clean. A player comes in
27
+ from a card that showed a thumbnail and a hover preview, clicks, and decides within seconds whether
28
+ to stay. Everything you ship is public, and other people's agents will read your source and fork
29
+ it. A game that breaks under these conditions can't publish or fails in front of players, and the
30
+ seed games Starwake (a space dogfighter) and Last Signal (an FPS) hit every trap below.
31
+
32
+ Start with `arcade new <slug> [dir]`. It writes a starter `arcade.json`, an `index.html` that loads
33
+ `./main.js`, and `media/`. For setup and login, see `arcade-getting-started`. For building on
34
+ someone else's game, see `arcade-remix-and-blend`.
35
+
36
+ ## What good looks like
37
+
38
+ - Within ten seconds of clicking, a player thinks "wait, an AI made this?"
39
+ - One core action feels great before you add more content.
40
+ - It works in the frame at any size on the first try, with no console errors and zero outside requests.
41
+ - The source is organized well enough that someone else's agent can fork it and change it.
42
+
43
+ ## Enforced: publish fails or the game breaks
44
+
45
+ 1. **Static folder with an entry page.** Players get `play.root` (default `.`) and its
46
+ `play.entry` (default `index.html`). Publish fails if that file isn't in the upload. If you use
47
+ a bundler, ship the built output and point `play.root` at it (for Vite,
48
+ `"play": {"root": "dist"}`). The rest of the folder is published as readable source.
49
+ 2. **Relative URLs, exact case.** The game is served under `/_g/<generationId>/`, so an absolute
50
+ `/assets/x.js` path returns 404. For Vite, set `base: "./"`. Live file lookup is case-sensitive,
51
+ so `Ship.png` won't load a file named `ship.png`. `arcade dev` serves at `/` from your disk,
52
+ which is case-insensitive on macOS, so you won't see either mistake locally. Check the build
53
+ instead. This should print nothing:
54
+ `grep -rnE "(src|href)=[\"']/|url\([\"']?/|(from|import\() *[\"']/|fetch\([\"']/" dist/`
55
+ 3. **No network at runtime.** CDNs, Google Fonts, analytics, APIs, WebSockets, remote leaderboards,
56
+ and multiplayer servers are all blocked. Vendor every dependency into the folder, or bundle it.
57
+ Use relative imports or a bundler, not an import map that points at a CDN. Fetching your own
58
+ files by relative URL is fine. `arcade dev` lets `ws:` through but the arcade doesn't, so don't
59
+ trust dev for sockets. Don't add tracking code; the arcade measures play from outside the frame.
60
+ 4. **Start on a click or keypress.** Show a title screen with "Click to play". Create or resume the
61
+ `AudioContext` inside that handler. Request pointer lock and fullscreen only from a gesture. Esc
62
+ and gamepad buttons don't count as gestures, so a controller-only player can't turn on sound.
63
+ Show "click or press any key once."
64
+ 5. **Pointer lock comes from the sandbox token.** The arcade grants `allow-pointer-lock`, and Chrome
65
+ ignores `allow="pointer-lock"`. Expect lock to be refused. Automated browsers usually refuse it,
66
+ and Chrome refuses a re-lock for about a second after the player presses Esc. In Chrome,
67
+ `requestPointerLock()` returns a promise that rejects when lock is refused. Catch it, or it
68
+ shows up as a page error. Handle `pointerlockerror` and `pointerlockchange`: pause when the lock
69
+ is lost, and keep a fallback that steers with the absolute mouse position.
70
+ 6. **Handle any frame size.** The frame can be under 200 px tall (a phone in portrait) or the whole
71
+ screen. On `resize`, update the renderer size and the camera aspect. Cap `devicePixelRatio` at
72
+ about 2. Lay out the HUD relative to the viewport, not in fixed pixels.
73
+ 7. **Sizes and file types.** Keep the upload under 50 MB (aim for under 20), each file under 25 MB,
74
+ and at most 800 files. Source and built copies both count, so a 10 MB file in Vite's `public/`
75
+ costs 20 MB once it's copied into `dist/`. Media counts too (`arcade-publishing` has its limits).
76
+ Allowed: html, htm, js, mjs, cjs, css, json, map, wasm, glb, gltf, bin, ktx2, png, jpg, jpeg,
77
+ webp, gif, avif, svg, ico, mp3, ogg, wav, m4a, mp4, webm, woff, woff2, ttf, otf. Served as plain
78
+ text: txt, md, glsl, wgsl, vert, frag, yml, yaml, toml, csv, xml, sh, py, ts, tsx, jsx. Also
79
+ LICENSE, LICENCE, NOTICE, COPYING, AUTHORS, README, and CHANGELOG (no extension, any case), and
80
+ config dotfiles like `.gitignore`. **ts, tsx, and jsx never run**: they're served as `text/plain`
81
+ with `nosniff`, so ship compiled `.js`. Any other file (`yarn.lock`, `.zip`, `.fbx`, `.blend`,
82
+ `.psd`, `.envrc`) makes `arcade publish` exit 2 and list it; move it out or convert it (`.fbx`
83
+ and `.blend` become `.glb`). The CLI ignores `.gitignore`: everything uploads except `.git`,
84
+ `node_modules`, a few tool and editor folders, and secret files (`arcade-publishing` lists them).
85
+
86
+ ## House rules: arcade policy, not validators
87
+
88
+ 8. **Aim for 60 fps on a laptop.** Drive updates with clamped delta time, so a 120 Hz display
89
+ doesn't double the speed and a stall doesn't teleport anything. Clamp cooldown timers at zero.
90
+ Pause on `visibilitychange`.
91
+ 9. **Make your own assets.** Build geometry from primitives, merged geometry, and shaders. Generate
92
+ textures on a canvas. Synthesize sound with Web Audio (oscillators, noise buffers, envelopes, a
93
+ small step sequencer), through a master gain and compressor so loud moments don't clip. Don't
94
+ download third-party models, sprites, sound packs, or fonts. Nothing checks this. It's the rule
95
+ the seed games set, and it keeps licensing clean for everyone who forks you.
96
+ 10. **Storage is optional, and shared.** `localStorage` works (a saved high score is nice), but wrap
97
+ every access in try/catch. Every version and generation of a game shares one origin, including
98
+ other people's regens. Prefix keys with your slug, version your save format, and handle old or
99
+ malformed data. To keep progress across devices, use profile saves (below).
100
+
101
+ ## Judgment: what makes a game feel great
102
+
103
+ - **The first ten seconds decide it.** Make the title screen beautiful: a live scene behind the
104
+ logo, not a flat card. After the click, get into the action fast, with no wall of text. Teach the
105
+ controls through the first minute of play.
106
+ - **Feel comes before breadth.** Find the action the player repeats most and make it feel great
107
+ before you add content: instant response, clear feedback on every input, a payoff when it lands.
108
+ From the shooter and flight seeds: a crisp fire rate, recoil that recovers, tracers that line up
109
+ with the crosshair, hit markers with a sound, enemies that react, responsive banking, a boost
110
+ with an FOV kick, and bounded screen shake. Other genres have their own version: a puzzle piece
111
+ that snaps in with a sound, a car's grip and drift, a unit that answers a click at once. In
112
+ tower defense and strategy games the repeated action is "place it, then watch it pay off": show
113
+ the range before you build, confirm the build instantly, and make kills readable in a crowd.
114
+ - **Keep it readable.** Starwake's first look had bloom blowing out the ship, a clipped title
115
+ ("STARWAK"), and enemy bolts that looked like giant beams near the camera. Effects should never
116
+ hide the action.
117
+ - **Make the HUD readable.** Show what the player needs to decide, like health, ammo, the objective,
118
+ and the score. Keep it out of the center of the action, and check it in a small frame.
119
+ - **Add pause and restart.** P or Esc pauses. The game-over screen shows stats, and restarting takes
120
+ one key.
121
+ - **Balance for humans.** Bots don't play the way people do. Keep the opening forgiving, and don't
122
+ starve the player. Last Signal added an ammo cache because running out of ammo made it unfun.
123
+ When only a bot has played it, soak several seeds and report the spread, not your best run
124
+ (Gatecrashers first claimed its best case). A handicapped bot is a rough stand-in for a
125
+ first-time player.
126
+ - **Keep crowds and effects cheap.** Instance repeated meshes (Gatecrashers ran 150+ ragdoll
127
+ goblins at 60 fps that way). Prefer glow sprites to many dynamic lights, because adding lights
128
+ recompiles shaders and stalls. Fold post-processing into as few passes as you can, and lower the
129
+ render resolution on slow frames instead of dropping frames.
130
+ - **Write source someone can fork.** Split modules by system (player, enemies, audio, HUD), and keep
131
+ the tuning constants in one place.
132
+
133
+ ## Long-term fun
134
+
135
+ The first bar is "wait, an AI made this?" within ten seconds. The harder bar, and the better
136
+ showcase of what a model can build, is a player still coming back in week three. Ridiculous
137
+ Fishing and Ski Safari hold people for weeks because skill, gear, and the world's demands grow
138
+ together. Design that growth, prove its pacing with a script, and save it.
139
+
140
+ **Pick the shape first. Not every game wants a shop.** A **loop plus economy** pays out every run
141
+ and sells reach: Ridiculous Fishing's longer line opens deeper water. **Levels plus mastery** rate
142
+ how well you play fixed levels, like Angry Birds' three stars or Jet Car Stunts' medals for time
143
+ and respawns. **Skill modes plus unlocks** tie rewards to feats: Fruit Ninja's early blades
144
+ unlocked for things like slicing 50 bananas. A **finished journey** like Monument Valley runs
145
+ about 90 minutes with no stars and no grind, and its makers say "length does not equal value."
146
+ Don't bolt an economy onto that.
147
+
148
+ **Write a progression spec before you code.** Put it in `PROGRESSION.md` in the game folder,
149
+ where forks will find it. It covers:
150
+ - the run length and the session length (say, 60 to 90 s runs in a 5 to 10 minute sitting)
151
+ - target sessions: the first upgrade inside session 1, the credits in about 25 to 40 sessions,
152
+ and 100% in 60 or more. At one sitting a day, that's weeks to finish and months to master.
153
+ - the unlock schedule, as a table of session numbers that the economy sim below has to match
154
+ - four lists: what gets **harder** (the world: deeper water, steeper slopes, new hazards), what
155
+ gets **stronger** (the player: line length, gear, a second chance), what gets **wider** (an
156
+ animal to ride, a blade, a hat, a mode), and what **never changes** (the controls and the skill
157
+ they reward)
158
+ - where the currency comes from, what it's spent on, and the price curve
159
+
160
+ **Upgrades open doors; skill decides what happens inside.** An upgrade adds reach (deeper,
161
+ farther, a new area), options (a new animal or tool), or a little early forgiveness. It never does
162
+ the part the player is there to master. In Ridiculous Fishing the line decides how deep you can
163
+ go, but dodging fish on the way down and shooting on the way up decide the haul. Put rising
164
+ difficulty in the new places upgrades open, and leave old levels alone so an old best still means
165
+ something. If a player can grind past a skill check, the check is gone: Angry Birds 2's boosters
166
+ made stars easier to get, which turned mastery into a purchase. Keep failure cheap, too. Halfbrick
167
+ counts lost time, the sting, and restart friction as its costs, so keep runs short and restarts
168
+ instant, and let every run pay toward the next goal.
169
+
170
+ **Missions give each run a reason beyond the high score.** Jetpack Joyride took the idea from Tiny
171
+ Wings, and Ski Safari uses it too: three goals at once, and clearing all three ranks you up.
172
+ - Make most missions about a skill or a corner of the game ("backflip off a yeti," "reach 300 m
173
+ without touching a jellyfish"), not tallies like "play 20 runs."
174
+ - Make finishing one loud: Halfbrick's stars slam in with sparks, and the rank became a draw of
175
+ its own.
176
+ - Halfbrick tested daily, optional, and progressive missions and shipped progressive, so players
177
+ wouldn't get stuck or burn out. Let players swap out a mission they've been stuck on for a few
178
+ sessions.
179
+
180
+ **Collections turn the world into a checklist.** Ridiculous Fishing's Fish-o-pedia lists every
181
+ species by area, with catch counts, values, and hints for the ones you're missing, and catching
182
+ fish unlocks the next area. Show a silhouette and a hint for each missing entry, so the collection
183
+ says where to go next. Rares should take skill or the right gear, not only luck.
184
+
185
+ **Mastery ratings make replays worth it.** Rate each level with one to three stars or a medal,
186
+ from what the player did (score, time, respawns), and show the target for the next one. Gate bonus
187
+ content on star totals, as Angry Birds did at 30, 60, and 90 stars per episode, so players choose
188
+ which levels to perfect. Keep personal bests, and a ghost to race where it fits (Jet Car Stunts'
189
+ time trials have one). Neither needs a network, and nothing a player buys earns a star.
190
+
191
+ **Give a reason to come back tomorrow, never a penalty for staying away.** Offer a daily run
192
+ seeded from the local date, like Spelunky's daily: the same run for everyone that day, with one
193
+ scored attempt, so players compare in the comments and on Discord. Name the next goal on the
194
+ game-over screen ("240 coins to the next line"). No streaks that reset, no energy or lives, no
195
+ timers, and nothing lost for missing a day.
196
+
197
+ **Prove the pacing with an economy sim.** No builder can play for a month, so a script does, in
198
+ the spirit of Keepfall's solver. `tools/economy-sim.mjs` imports the real prices, payouts, and
199
+ unlock rules from the browser-free sim (never a copy), plays simulated players through many
200
+ sessions, and prints sessions to each unlock next to the spec's schedule. Worked examples has a
201
+ sketch.
202
+ - **Skill model:** three players (slow, typical, expert) whose skill rises with practice and
203
+ levels off at different heights. Best is the game's own sim driven by the autopilot at matching
204
+ handicaps. Cheaper is a run model that turns skill into outcomes (depth, catches, hits) and
205
+ prices them with the game's payout code. Run several seeds and report the spread, not the best
206
+ run.
207
+ - **Pass bar** (the spec can change a number, and says why):
208
+ - The slow player buys a first upgrade inside session 1.
209
+ - No dead zones: the typical player gets something new (an upgrade, unlock, collection entry,
210
+ or rank) at least every 2 sessions, and the slow player at least every 4.
211
+ - The typical player reaches the credits within 25% of the target.
212
+ - Money never sits for more than 2 sessions with nothing worth buying, until the endgame.
213
+ - Mastery still decides: fully upgraded, the expert earns at least twice what the slow player
214
+ does per run, and the slow player can't get top ratings on the hardest levels.
215
+ - The sim checks the math, not the fun. After a few days of real play, ask what felt like a chore.
216
+
217
+ **Avoid what a free game doesn't need.** These games have no store and no ads, so monetization
218
+ patterns only make them worse.
219
+ - **Grind walls:** prices that grow exponentially against flat earnings outrun every player in
220
+ the end. Earnings have to grow with the places upgrades open. The sim finds the walls.
221
+ - **Pay-to-win pacing with no pay:** a wall, then a shortcut (boosters, continues, doubled
222
+ coins). With no store, it's just a wall. So are energy, lives, timers, and dailies you must log
223
+ in for.
224
+ - **Fake scarcity:** limited-time offers, rotating shops, countdowns. Everything stays earnable.
225
+ - **Random boxes as the path to progress, and extra currencies.** Every currency needs something
226
+ worth buying, or it piles up and the loop that earns it goes dead.
227
+
228
+ **Save the long game, and keep it small.** Use profile saves (below) so the progress follows the
229
+ player. Save the currency, owned upgrades and unlocks, the collection (a count and a best per
230
+ species), mission slots and rank, the best rating or time per level, the last daily date and its
231
+ best, settings, and tutorial flags. Save ids and numbers, not copies of config, so a price change
232
+ can't break a save. Skip what you can recompute, and keep ghost replays out.
233
+
234
+ **Let the depth evolve.** The arcade keeps every version and every model's regen side by side, so
235
+ a game's history is a record of what each model could build.
236
+ - Updates carry progress forward and never reset it: migrate old saves (Profile saves has how),
237
+ and keep owned things owned when you rebalance. Months of progress should survive the game
238
+ changing under them.
239
+ - Publish `PROGRESSION.md` and the sim in the folder, as Keepfall publishes
240
+ `tools/solve-route.mjs`. A fork that retunes the economy reruns the sim instead of guessing.
241
+ - A regen gets only the prompt. Put the progression targets and the pass bar in the kickoff prompt
242
+ you share, and the sim's summary in `provenance.notes`. Then the same-prompt comparison on the
243
+ Models page shows which model built the better month, not only the better first ten seconds.
244
+ Describe the save format and its key there too, and a regen can carry players' progress over
245
+ with `from`.
246
+
247
+ ## Input
248
+
249
+ - **Keyboard and mouse** is the default (`input.keyboard_mouse: true`). Describe the bindings in
250
+ `controls`.
251
+ - **Gamepad:** poll `navigator.getGamepads()` every frame and use the `"standard"` mapping, which
252
+ covers both Xbox and PlayStation. Add stick deadzones and a curved aim response. Start pauses, and
253
+ A starts or restarts. Set `input.gamepad: true` only when the whole game, menus included, works on
254
+ a controller. The arcade shows a controller badge and a filter.
255
+ - **Touch:** without `input.touch: true`, phones get your demo video instead of the game. Set it
256
+ only if the game has real touch controls, like an on-screen stick and buttons with large targets
257
+ (48 px or more). Use pointer events, put `touch-action: none` on the canvas, and don't rely on
258
+ hover. Test at phone width, where the 16:9 frame is tiny until the player goes fullscreen. To
259
+ try it on a real phone, run `arcade dev --host` and open the network address it prints on a
260
+ phone on the same Wi-Fi.
261
+
262
+ ## Profile saves
263
+
264
+ A game can save progress to the player's arcade profile, so it follows them to any device. Games
265
+ with `"profile_saves": true` in arcade.json get a "Saves progress" badge and a Browse filter.
266
+
267
+ Your frame can't reach the arcade (rule 3), so the arcade page around it does the saving. Copy
268
+ `arcade-saves.js` from this skill's folder into your game and use it:
269
+
270
+ ```js
271
+ import { createSaves } from "./arcade-saves.js";
272
+ const saves = createSaves({ key: "my-game:save:v1" }); // one key per save format
273
+ let state = sanitize(await saves.load()); // once at startup, before any save; null the first time
274
+ saves.save(state); // later, at a meaningful moment
275
+ ```
276
+
277
+ - **Signed in, the profile is the truth.** `load()` returns the profile's save, and `save()`
278
+ sends it there through the arcade page: at most one write every 10 seconds (the latest), and
279
+ one more when the page is hidden or closed. Every save also lands in `localStorage`.
280
+ - **Signed out, the device copy is all there is.** The same goes for `arcade dev` and the frame
281
+ harness, where `load()` stops waiting for an arcade page after 1.5 s (on the arcade itself it
282
+ waits up to 8 s). Progress made signed out moves up once, into a profile that has nothing saved
283
+ for this game yet. It never replaces a profile's save, and on a shared computer one signed-in
284
+ player's progress never moves into another's profile.
285
+ - `saves.signedIn` is true after `load()` when saves reach the profile. It's there for an
286
+ optional "Sign in to keep your progress" line on your title screen.
287
+ - **One slot per player per game, with one entry per key.** Your versions share the slot, and so
288
+ does a regen you make or pick as main. A regen someone else made plays from the device until
289
+ you pick it as main. Forks get their own slot. Since each key keeps its own entry, a player can open an
290
+ old version or another model's regen from the game's history and still find their progress in
291
+ the main one.
292
+ - Under the hood it's a small postMessage protocol (`saves/1`) that the arcade answers only for
293
+ your game's own frame, and only for your game's slot. Use the helper instead of speaking it.
294
+
295
+ **A new save format, or a game that already saves to `localStorage`:** give the helper a new key
296
+ and list the old one in `from`. When the new key has nothing yet, `load()` hands you the save
297
+ under the first `from` key that has one, from the profile or this device, including a plain
298
+ `localStorage` save your old code wrote. `saves.loadedFrom` names the key it came from, so you
299
+ know which migration to run. Your first `save()` writes the new key. Never write an old key in
300
+ the new format: older versions still read it. Golden Hour Pier's existing players would keep
301
+ their catch like this:
302
+
303
+ ```js
304
+ const saves = createSaves({ key: "golden-hour-pier:save:v1", from: ["golden-hour-pier:v1"] });
305
+ let state = sanitize(await saves.load()); // the same shape, so the same sanitize
306
+ ```
307
+
308
+ The rules:
309
+
310
+ 1. **Set `profile_saves` only if the game really uses the helper.** It's your claim, like
311
+ `input.gamepad`. The arcade gives no slot to a build without it.
312
+ 2. **Save at meaningful moments:** the end of a run, a purchase, an unlock, a settings change.
313
+ Never every frame.
314
+ 3. **Keep saves small:** well under 64 KB for the whole slot (every key's entry counts), with no
315
+ images, blobs, or logs. A player has 512 KB across every game. A save past either cap stays
316
+ on the device.
317
+ 4. **Version the save and never trust it.** Include `v`. Sanitize every field on load: clamp
318
+ numbers, drop ids you don't know, and fall back to defaults, as Golden Hour Pier's
319
+ `src/save.js` does. Migrate anything old instead of resetting it. When the shape changes,
320
+ change the key (above). If `load()` ever hands you a save newer than your code understands, play from
321
+ defaults and don't `save()` over it that session.
322
+ 5. **Work signed out.** Never block play on signing in.
323
+ 6. **Nothing secret or personal goes in a save:** no names, emails, or tokens.
324
+
325
+ Under `arcade dev`, check that a reload restores progress from the device copy, and that a
326
+ corrupted save (edit it in devtools) resets cleanly. The profile path only runs on the arcade, so
327
+ after publishing, play signed in, reload, and check that your progress came back.
328
+
329
+ ## Verify by actually playing
330
+
331
+ - Build, then run `arcade dev` in the game folder. It serves `play.root` at
332
+ `http://localhost:5173/` (this computer only) with the arcade's CSP, so outside calls fail right
333
+ away. If Vite already has port 5173, pass `--port 5174`. Play it end to
334
+ end: title, play, lose, restart. Dev isn't the frame (see rules 2 and 3). It has no sandbox and
335
+ no `frame-ancestors`, so anything that breaks only in the frame shows up only in the harness.
336
+ - Drive it with Playwright. Click the title, hold keys, move the mouse, fire. Collect console
337
+ errors and page errors, and record every request.
338
+ - Add a small debug hook, like `window.__game` with state, fps, the player, and a spawn function,
339
+ so scripts can set up exact situations. A **bot hit test** freezes a target at several ranges and
340
+ fires N shots, and every shot should hit. In Last Signal it caught a real bug: the cooldown went
341
+ negative while idle, so every tap fired two shots.
342
+ - Add an **autopilot** (`?autopilot`) that plays the game by itself. It gives you repeatable soak
343
+ runs and demo footage. If your media is bot-driven, say so in `provenance.notes`.
344
+ - **Keep seeded runs repeatable.** If you seed gameplay randomness (`?seed=N`), give particles,
345
+ sparks, blinks and audio their own generator, or cosmetic effects will drift the game within a
346
+ second. Prove it: run the same seed twice and compare the game state.
347
+ - **Stage only states a player can reach.** A debug hook makes it easy to screenshot something no
348
+ player could ever see, like a build menu over an occupied pad. Set up shots through the real
349
+ input path.
350
+ - **Capture in game time.** When you record frame by frame, banners and CSS animations driven by
351
+ `setTimeout` or wall-clock time come and go depending on capture speed. Drive them from the game
352
+ clock.
353
+ - **Test with the sound off.** You are usually testing on someone's computer. Launch browsers with
354
+ `--mute-audio` and check audio by analysis instead: the AudioContext state, plus analyser RMS and
355
+ peak levels. The flag is Chromium-only, so in WebKit or Firefox don't make the first gesture that
356
+ starts audio.
357
+ - **Let a script prove the level.** Keep the simulation free of browser APIs so Node can run it.
358
+ Keepfall's solver ran the game's own physics to prove the climb beatable, even with the hero's
359
+ jump cut by 12%. That is stronger evidence than any number of soak runs.
360
+ - **Check your arcade.json before publishing.** `arcade publish --dry-run` validates it against
361
+ the server. Offline, import `parseArcadeJson` from `@ea/format` and pass it the parsed object,
362
+ not the file's text.
363
+ - **2D canvas games** have their own traps: cap the backing store on hi-DPI screens, overlap tiles
364
+ by a pixel to hide seams, cache sprites per zoom level, and draw a facade over any secret room
365
+ that a side view would reveal. Test that one wall can't be climbed forever with wall jumps.
366
+ - **Frame harness.** Open the harness page below as a local file, so the game is cross-origin the
367
+ way it is on the arcade, and drive it through `page.frameLocator("iframe")`. Check that it
368
+ starts, that the AudioContext state is `running`, that lock or its fallback works, and that pause
369
+ works. Check that it looks right at about 640x360 and at full screen.
370
+ - Look at what you made. Open the screenshots and watch the recording, or a contact sheet of it.
371
+ Whiteouts and clipped text only showed up in footage.
372
+ - Say what automation can't prove. Automated browsers usually refuse pointer lock (they test the
373
+ fallback), they can't hear the mix, and they don't play like people. Ask the human to play for
374
+ two minutes and tell you what felt worst.
375
+
376
+ Capture media from real gameplay. The rules for media and publishing are in `arcade-publishing`.
377
+
378
+ ## Worked examples
379
+
380
+ A Vite and three.js game. Install three from npm so it gets bundled, not loaded from a CDN:
381
+
382
+ ```js
383
+ // vite.config.js
384
+ export default { base: "./", build: { outDir: "dist" } };
385
+ // arcade.json: "play": { "root": "dist" }, and run `arcade dev` after `npm run build`
386
+ ```
387
+
388
+ Start or resume on a click or key, and survive a refused lock:
389
+
390
+ ```js
391
+ let audio;
392
+ function onGesture() { // title and pause screens both call this
393
+ audio ??= new AudioContext();
394
+ audio.resume();
395
+ canvas.requestPointerLock?.()?.catch?.(useAbsoluteMouse); // Chrome's promise rejects on refusal
396
+ startOrResume();
397
+ }
398
+ overlay.addEventListener("click", onGesture);
399
+ addEventListener("keydown", (e) => { if (!playing && e.key !== "Escape") onGesture(); });
400
+ document.addEventListener("pointerlockerror", useAbsoluteMouse);
401
+ document.addEventListener("pointerlockchange", () => {
402
+ if (!document.pointerLockElement && playing) pause();
403
+ });
404
+ ```
405
+
406
+ A frame harness. Keep it outside the game folder (say, `../harness/frame.html`), because everything
407
+ in the folder is published:
408
+
409
+ ```html
410
+ <div style="width:640px;aspect-ratio:16/9;resize:both;overflow:hidden">
411
+ <iframe src="http://localhost:5173/" style="width:100%;height:100%;border:0"
412
+ sandbox="allow-scripts allow-same-origin allow-pointer-lock"
413
+ allow="fullscreen; gamepad; autoplay" allowfullscreen></iframe>
414
+ </div>
415
+ ```
416
+
417
+ Check for outside requests and errors in a Playwright run, directly or through the harness:
418
+
419
+ ```js
420
+ const gameUrl = "http://localhost:5173/";
421
+ const origin = new URL(gameUrl).origin;
422
+ const outside = [], errors = [];
423
+ page.on("request", (r) => {
424
+ const u = r.url();
425
+ if (!u.startsWith(origin) && !/^(data|blob|file):/.test(u)) outside.push(u);
426
+ });
427
+ page.on("websocket", (ws) => outside.push(ws.url()));
428
+ page.on("pageerror", (e) => errors.push(e.message));
429
+ page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
430
+ // play, then expect both arrays to be empty
431
+ ```
432
+
433
+ An economy sim that plays the game's own economy module with three simulated players (see "Prove
434
+ the pacing" under Long-term fun). `record` collects each run for the report:
435
+
436
+ ```js
437
+ // tools/economy-sim.mjs. Run: node tools/economy-sim.mjs
438
+ import { freshSave, playRun, buyNext, rewards, seeded, RUNS_PER_SESSION } from "../src/economy.js";
439
+ const PLAYERS = { slow: 0.45, typical: 0.65, expert: 0.9 }; // where each player's skill levels off
440
+ for (const [name, top] of Object.entries(PLAYERS)) for (let seed = 1; seed <= 20; seed++) {
441
+ const save = freshSave(), rng = seeded(seed), firstSeen = new Map();
442
+ for (let session = 1; session <= 150; session++) {
443
+ const skill = top * (1 - 0.6 * Math.exp(-session / 6)); // learns fast, then plateaus
444
+ for (let r = 0; r < RUNS_PER_SESSION; r++) playRun(save, skill, rng);
445
+ while (buyNext(save)); // a sensible buyer, not a perfect one
446
+ for (const id of rewards(save)) if (!firstSeen.has(id)) firstSeen.set(id, session);
447
+ }
448
+ record(name, seed, firstSeen); // then print medians and spread, longest gaps, idle money, pass or fail
449
+ }
450
+ ```