evolutionary-arcade 0.0.1 → 0.1.1

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,43 @@
1
1
  {
2
2
  "name": "evolutionary-arcade",
3
- "version": "0.0.1",
4
- "description": "Evolutionary Arcade creator CLI. Coming soon.",
3
+ "version": "0.1.1",
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
+ "CHANGELOG.md",
16
+ "LICENSE"
7
17
  ],
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/DamDam98/evolutionary-arcade.git"
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "homepage": "https://evolutionaryarcade.com",
22
+ "keywords": [
23
+ "games",
24
+ "arcade",
25
+ "ai",
26
+ "agents",
27
+ "cli",
28
+ "threejs"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "typecheck": "tsc -p tsconfig.json",
33
+ "test": "vitest run"
34
+ },
35
+ "dependencies": {
36
+ "commander": "15.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@ea/format": "0.0.0",
40
+ "@types/node": "26.6.3",
41
+ "tsup": "8.5.1"
11
42
  }
12
43
  }
@@ -0,0 +1,602 @@
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, touch, or phone tilt support, saving progress to the player's profile, posting scores to global leaderboards, 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 allow-orientation-lock"
15
+ allow="fullscreen; gamepad; autoplay; accelerometer; gyroscope; magnetometer" 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, and top-level
21
+ navigation all fail. `screen.orientation.lock()` works only in fullscreen on Android. The camera,
22
+ microphone, and geolocation are off; the motion sensors are on for tilt games (Phone tilt, below). 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 (the arcade's own leaderboards and saves go through the
57
+ page; see below). Vendor every dependency into the folder, or bundle it.
58
+ Use relative imports or a bundler, not an import map that points at a CDN. Fetching your own
59
+ files by relative URL is fine. `arcade dev` lets `ws:` through but the arcade doesn't, so don't
60
+ trust dev for sockets. Don't add tracking code; the arcade measures play from outside the frame.
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
63
+ and gamepad buttons don't count as gestures, so a controller-only player can't turn on sound.
64
+ Show "click or press any key once."
65
+ 5. **Pointer lock comes from the sandbox token.** The arcade grants `allow-pointer-lock`, and Chrome
66
+ ignores `allow="pointer-lock"`. Expect lock to be refused. Automated browsers usually refuse it,
67
+ and Chrome refuses a re-lock for about a second after the player presses Esc. In Chrome,
68
+ `requestPointerLock()` returns a promise that rejects when lock is refused. Catch it, or it
69
+ shows up as a page error. Handle `pointerlockerror` and `pointerlockchange`: pause when the lock
70
+ is lost, and keep a fallback that steers with the absolute mouse position.
71
+ 6. **Handle any frame size.** The frame can be under 200 px tall (a phone in portrait) or the whole
72
+ screen. On `resize`, update the renderer size and the camera aspect. Cap `devicePixelRatio` at
73
+ about 2. Lay out the HUD relative to the viewport, not in fixed pixels.
74
+ 7. **Sizes and file types.** Keep the upload under 50 MB (aim for under 20), each file under 25 MB,
75
+ and at most 800 files. Source and built copies both count, so a 10 MB file in Vite's `public/`
76
+ costs 20 MB once it's copied into `dist/`. Media counts too (`arcade-publishing` has its limits).
77
+ Allowed: html, htm, js, mjs, cjs, css, json, map, wasm, glb, gltf, bin, ktx2, png, jpg, jpeg,
78
+ webp, gif, avif, svg, ico, mp3, ogg, wav, m4a, mp4, webm, woff, woff2, ttf, otf. Served as plain
79
+ text: txt, md, glsl, wgsl, vert, frag, yml, yaml, toml, csv, xml, sh, py, ts, tsx, jsx. Also
80
+ LICENSE, LICENCE, NOTICE, COPYING, AUTHORS, README, and CHANGELOG (no extension, any case), and
81
+ config dotfiles like `.gitignore`. **ts, tsx, and jsx never run**: they're served as `text/plain`
82
+ with `nosniff`, so ship compiled `.js`. Any other file (`yarn.lock`, `.zip`, `.fbx`, `.blend`,
83
+ `.psd`, `.envrc`) makes `arcade publish` exit 2 and list it; move it out or convert it (`.fbx`
84
+ and `.blend` become `.glb`). The CLI ignores `.gitignore`: everything uploads except `.git`,
85
+ `node_modules`, a few tool and editor folders, and secret files (`arcade-publishing` lists them).
86
+
87
+ ## House rules: arcade policy, not validators
88
+
89
+ 8. **Aim for 60 fps on a laptop.** Drive updates with clamped delta time, so a 120 Hz display
90
+ doesn't double the speed and a stall doesn't teleport anything. Clamp cooldown timers at zero.
91
+ Pause on `visibilitychange`.
92
+ 9. **Make your own assets.** Build geometry from primitives, merged geometry, and shaders. Generate
93
+ textures on a canvas. Synthesize sound with Web Audio (oscillators, noise buffers, envelopes, a
94
+ small step sequencer), through a master gain and compressor so loud moments don't clip. Don't
95
+ download third-party models, sprites, sound packs, or fonts. Nothing checks this. It's the rule
96
+ the seed games set, and it keeps licensing clean for everyone who forks you. Your game is MIT,
97
+ like every game here. Anything you do bundle from others, like a vendored Three.js, keeps its
98
+ own license: ship its LICENSE beside it (`vendor/three/LICENSE`), and only bundle what's yours
99
+ to share.
100
+ 10. **Storage is optional, and shared.** `localStorage` works (a saved high score is nice), but wrap
101
+ every access in try/catch. Every version and generation of a game shares one origin, including
102
+ other people's regens. Prefix keys with your slug, version your save format, and handle old or
103
+ malformed data. To keep progress across devices, use profile saves (below).
104
+
105
+ ## Judgment: what makes a game feel great
106
+
107
+ - **The first ten seconds decide it.** Make the title screen beautiful: a live scene behind the
108
+ logo, not a flat card. After the click, get into the action fast, with no wall of text. Teach the
109
+ controls through the first minute of play.
110
+ - **Feel comes before breadth.** Find the action the player repeats most and make it feel great
111
+ before you add content: instant response, clear feedback on every input, a payoff when it lands.
112
+ From the shooter and flight seeds: a crisp fire rate, recoil that recovers, tracers that line up
113
+ with the crosshair, hit markers with a sound, enemies that react, responsive banking, a boost
114
+ with an FOV kick, and bounded screen shake. Other genres have their own version: a puzzle piece
115
+ that snaps in with a sound, a car's grip and drift, a unit that answers a click at once. In
116
+ tower defense and strategy games the repeated action is "place it, then watch it pay off": show
117
+ the range before you build, confirm the build instantly, and make kills readable in a crowd.
118
+ - **Keep it readable.** Starwake's first look had bloom blowing out the ship, a clipped title
119
+ ("STARWAK"), and enemy bolts that looked like giant beams near the camera. Effects should never
120
+ hide the action.
121
+ - **Make the HUD readable.** Show what the player needs to decide, like health, ammo, the objective,
122
+ and the score. Keep it out of the center of the action, and check it in a small frame.
123
+ - **Add pause and restart.** P or Esc pauses. The game-over screen shows stats, and restarting takes
124
+ one key.
125
+ - **Balance for humans.** Bots don't play the way people do. Keep the opening forgiving, and don't
126
+ starve the player. Last Signal added an ammo cache because running out of ammo made it unfun.
127
+ When only a bot has played it, soak several seeds and report the spread, not your best run
128
+ (Gatecrashers first claimed its best case). A handicapped bot is a rough stand-in for a
129
+ first-time player.
130
+ - **Keep crowds and effects cheap.** Instance repeated meshes (Gatecrashers ran 150+ ragdoll
131
+ goblins at 60 fps that way). Prefer glow sprites to many dynamic lights, because adding lights
132
+ recompiles shaders and stalls. Fold post-processing into as few passes as you can, and lower the
133
+ render resolution on slow frames instead of dropping frames.
134
+ - **Write source someone can fork.** Split modules by system (player, enemies, audio, HUD), and keep
135
+ the tuning constants in one place.
136
+
137
+ ## Long-term fun
138
+
139
+ The first bar is "wait, an AI made this?" within ten seconds. The harder bar, and the better
140
+ showcase of what a model can build, is a player still coming back in week three. Ridiculous
141
+ Fishing and Ski Safari hold people for weeks because skill, gear, and the world's demands grow
142
+ together. Design that growth, prove its pacing with a script, and save it.
143
+
144
+ **Pick the shape first. Not every game wants a shop.** A **loop plus economy** pays out every run
145
+ and sells reach: Ridiculous Fishing's longer line opens deeper water. **Levels plus mastery** rate
146
+ how well you play fixed levels, like Angry Birds' three stars or Jet Car Stunts' medals for time
147
+ and respawns. **Skill modes plus unlocks** tie rewards to feats: Fruit Ninja's early blades
148
+ unlocked for things like slicing 50 bananas. A **finished journey** like Monument Valley runs
149
+ about 90 minutes with no stars and no grind, and its makers say "length does not equal value."
150
+ Don't bolt an economy onto that.
151
+
152
+ **Write a progression spec before you code.** Put it in `PROGRESSION.md` in the game folder,
153
+ where forks will find it. It covers:
154
+ - the run length and the session length (say, 60 to 90 s runs in a 5 to 10 minute sitting)
155
+ - target sessions: the first upgrade inside session 1, the credits in about 25 to 40 sessions,
156
+ and 100% in 60 or more. At one sitting a day, that's weeks to finish and months to master.
157
+ - the unlock schedule, as a table of session numbers that the economy sim below has to match
158
+ - four lists: what gets **harder** (the world: deeper water, steeper slopes, new hazards), what
159
+ gets **stronger** (the player: line length, gear, a second chance), what gets **wider** (an
160
+ animal to ride, a blade, a hat, a mode), and what **never changes** (the controls and the skill
161
+ they reward)
162
+ - where the currency comes from, what it's spent on, and the price curve
163
+
164
+ **Upgrades open doors; skill decides what happens inside.** An upgrade adds reach (deeper,
165
+ farther, a new area), options (a new animal or tool), or a little early forgiveness. It never does
166
+ the part the player is there to master. In Ridiculous Fishing the line decides how deep you can
167
+ go, but dodging fish on the way down and shooting on the way up decide the haul. Put rising
168
+ difficulty in the new places upgrades open, and leave old levels alone so an old best still means
169
+ something. If a player can grind past a skill check, the check is gone: Angry Birds 2's boosters
170
+ made stars easier to get, which turned mastery into a purchase. Keep failure cheap, too. Halfbrick
171
+ counts lost time, the sting, and restart friction as its costs, so keep runs short and restarts
172
+ instant, and let every run pay toward the next goal.
173
+
174
+ **Missions give each run a reason beyond the high score.** Jetpack Joyride took the idea from Tiny
175
+ Wings, and Ski Safari uses it too: three goals at once, and clearing all three ranks you up.
176
+ - Make most missions about a skill or a corner of the game ("backflip off a yeti," "reach 300 m
177
+ without touching a jellyfish"), not tallies like "play 20 runs."
178
+ - Make finishing one loud: Halfbrick's stars slam in with sparks, and the rank became a draw of
179
+ its own.
180
+ - Halfbrick tested daily, optional, and progressive missions and shipped progressive, so players
181
+ wouldn't get stuck or burn out. Let players swap out a mission they've been stuck on for a few
182
+ sessions.
183
+
184
+ **Collections turn the world into a checklist.** Ridiculous Fishing's Fish-o-pedia lists every
185
+ species by area, with catch counts, values, and hints for the ones you're missing, and catching
186
+ fish unlocks the next area. Show a silhouette and a hint for each missing entry, so the collection
187
+ says where to go next. Rares should take skill or the right gear, not only luck.
188
+
189
+ **Mastery ratings make replays worth it.** Rate each level with one to three stars or a medal,
190
+ from what the player did (score, time, respawns), and show the target for the next one. Gate bonus
191
+ content on star totals, as Angry Birds did at 30, 60, and 90 stars per episode, so players choose
192
+ which levels to perfect. Keep personal bests, and a ghost to race where it fits (Jet Car Stunts'
193
+ time trials have one). Neither needs a network, and nothing a player buys earns a star.
194
+
195
+ **Give a reason to come back tomorrow, never a penalty for staying away.** Offer a daily run
196
+ seeded from the local date, like Spelunky's daily: the same run for everyone that day, with one
197
+ scored attempt, so players compare in the comments and on Discord. Name the next goal on the
198
+ game-over screen ("240 coins to the next line"). No streaks that reset, no energy or lives, no
199
+ timers, and nothing lost for missing a day.
200
+
201
+ **Prove the pacing with an economy sim.** No builder can play for a month, so a script does, in
202
+ the spirit of Keepfall's solver. `tools/economy-sim.mjs` imports the real prices, payouts, and
203
+ unlock rules from the browser-free sim (never a copy), plays simulated players through many
204
+ sessions, and prints sessions to each unlock next to the spec's schedule. Worked examples has a
205
+ sketch.
206
+ - **Skill model:** three players (slow, typical, expert) whose skill rises with practice and
207
+ levels off at different heights. Best is the game's own sim driven by the autopilot at matching
208
+ handicaps. Cheaper is a run model that turns skill into outcomes (depth, catches, hits) and
209
+ prices them with the game's payout code. Run several seeds and report the spread, not the best
210
+ run.
211
+ - **Pass bar** (the spec can change a number, and says why):
212
+ - The slow player buys a first upgrade inside session 1.
213
+ - No dead zones: the typical player gets something new (an upgrade, unlock, collection entry,
214
+ or rank) at least every 2 sessions, and the slow player at least every 4.
215
+ - The typical player reaches the credits within 25% of the target.
216
+ - Money never sits for more than 2 sessions with nothing worth buying, until the endgame.
217
+ - Mastery still decides: fully upgraded, the expert earns at least twice what the slow player
218
+ does per run, and the slow player can't get top ratings on the hardest levels.
219
+ - The sim checks the math, not the fun. After a few days of real play, ask what felt like a chore.
220
+
221
+ **Avoid what a free game doesn't need.** These games have no store and no ads, so monetization
222
+ patterns only make them worse.
223
+ - **Grind walls:** prices that grow exponentially against flat earnings outrun every player in
224
+ the end. Earnings have to grow with the places upgrades open. The sim finds the walls.
225
+ - **Pay-to-win pacing with no pay:** a wall, then a shortcut (boosters, continues, doubled
226
+ coins). With no store, it's just a wall. So are energy, lives, timers, and dailies you must log
227
+ in for.
228
+ - **Fake scarcity:** limited-time offers, rotating shops, countdowns. Everything stays earnable.
229
+ - **Random boxes as the path to progress, and extra currencies.** Every currency needs something
230
+ worth buying, or it piles up and the loop that earns it goes dead.
231
+
232
+ **Save the long game, and keep it small.** Use profile saves (below) so the progress follows the
233
+ player. Save the currency, owned upgrades and unlocks, the collection (a count and a best per
234
+ species), mission slots and rank, the best rating or time per level, the last daily date and its
235
+ best, settings, and tutorial flags. Save ids and numbers, not copies of config, so a price change
236
+ can't break a save. Skip what you can recompute, and keep ghost replays out.
237
+
238
+ **Let the depth evolve.** The arcade keeps every version and every model's regen side by side, so
239
+ a game's history is a record of what each model could build.
240
+ - Updates carry progress forward and never reset it: migrate old saves (Profile saves has how),
241
+ and keep owned things owned when you rebalance. Months of progress should survive the game
242
+ changing under them.
243
+ - Publish `PROGRESSION.md` and the sim in the folder, as Keepfall publishes
244
+ `tools/solve-route.mjs`. A fork that retunes the economy reruns the sim instead of guessing.
245
+ - A regen gets only the prompt. Put the progression targets and the pass bar in the kickoff prompt
246
+ you share, and the sim's summary in `provenance.notes`. Then the same-prompt comparison on the
247
+ Models page shows which model built the better month, not only the better first ten seconds.
248
+ Describe the save format and its key there too, and a regen can carry players' progress over
249
+ with `from`.
250
+
251
+ ## Input
252
+
253
+ - **Keyboard and mouse** is the default (`input.keyboard_mouse: true`). Describe the bindings in
254
+ `controls`.
255
+ - **Gamepad:** poll `navigator.getGamepads()` every frame and use the `"standard"` mapping, which
256
+ covers both Xbox and PlayStation. Add stick deadzones and a curved aim response. Start pauses, and
257
+ A starts or restarts. Set `input.gamepad: true` only when the whole game, menus included, works on
258
+ a controller. The arcade shows a controller badge and a filter.
259
+ - **Touch:** without `input.touch: true`, phones get your demo video instead of the game. Set it
260
+ only if the game has real touch controls, like an on-screen stick and buttons with large targets
261
+ (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
263
+ try it on a real phone, run `arcade dev --host` and open the network address it prints on a
264
+ phone on the same Wi-Fi.
265
+ - **Tilt:** see Phone tilt, next.
266
+
267
+ ## Phone tilt
268
+
269
+ Set `"input": { "touch": true, "tilt": true }` when tilting the phone steers the game. The game page
270
+ shows a Tilt badge. Tilt needs `touch` (publish fails without it), because phones only get games
271
+ that have touch on, and every tilt game needs touch controls anyway.
272
+
273
+ - **Ask on a tap.** iPhones send no motion events until the player allows them, and Safari only
274
+ asks from a tap. Call `requestPermission()` first thing in your "Tap to play" handler, before
275
+ any `await`. Android and desktop browsers don't ask.
276
+ - **Ship a touch fallback.** Players say no, iOS may not ask again, and computers have no sensors
277
+ (desktop Chrome sends one event with null angles). If permission isn't granted, or no event
278
+ with real numbers arrives within about a second, steer by touch, and let players switch in
279
+ settings.
280
+ - **Calibrate neutral on start.** Nobody holds a phone flat. Take the angle when play starts as
281
+ zero, steer by the difference with a small deadzone, clamp around 25 degrees, and smooth it a
282
+ little. Put "Recenter" in the pause menu.
283
+ - **Turn the axes with the screen.** `gamma` is left-right only in portrait. In landscape it's
284
+ `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.
289
+
290
+ ```js
291
+ const LANDSCAPE = true; // a landscape-first game
292
+ let tiltOk = false, zero = null, tilt = 0; // tilt: -1..1; steer by touch until tiltOk
293
+ async function onStartTap() { // the "Tap to play" handler
294
+ const DOE = globalThis.DeviceOrientationEvent;
295
+ const asking = typeof DOE?.requestPermission === "function"
296
+ ? DOE.requestPermission().catch(() => "denied") // iPhone: asks, so call it before any await
297
+ : Promise.resolve(DOE ? "granted" : "denied");
298
+ if (LANDSCAPE) document.documentElement.requestFullscreen?.()
299
+ .then(() => screen.orientation.lock("landscape")).catch(() => {}); // Android only
300
+ tiltOk = (await asking) === "granted";
301
+ zero = null; // "Recenter" does this too
302
+ startOrResume();
303
+ }
304
+ const across = (e) => { // left-right degrees, however it's turned
305
+ const a = ((screen.orientation?.angle ?? globalThis.orientation ?? 0) + 360) % 360;
306
+ return a === 90 ? e.beta : a === 270 ? -e.beta : a === 180 ? -e.gamma : e.gamma;
307
+ };
308
+ addEventListener("deviceorientation", (e) => {
309
+ if (!tiltOk || e.beta == null) return; // no sensor: keep touch steering
310
+ const x = across(e);
311
+ zero ??= x; // neutral is how they hold it at the start
312
+ const d = x - zero;
313
+ tilt = Math.abs(d) < 2 ? 0 : Math.max(-1, Math.min(1, d / 25));
314
+ });
315
+ ```
316
+
317
+ Testing tilt:
318
+ - Motion events only fire on HTTPS or localhost. In a desktop browser on `arcade dev`, set angles
319
+ in Chrome DevTools (More tools, Sensors). A phone on `arcade dev --host` gets plain http and no
320
+ events: on Android, add the address under `chrome://flags` "Insecure origins treated as
321
+ secure"; on an iPhone, use an HTTPS tunnel to the dev server.
322
+ - In Playwright, drive it with synthetic events in the game's frame:
323
+ `dispatchEvent(new DeviceOrientationEvent("deviceorientation", { beta: 10, gamma: 15 }))`.
324
+ Test the calibration, the clamp, and the touch fallback when no events arrive (the default in
325
+ an automated browser).
326
+ - After publishing, play it on a real iPhone in Safari and on an Android phone: the prompt shows
327
+ once on the tap, steering is centered where you hold it, and saying no leaves touch working.
328
+
329
+ ## Mute button
330
+
331
+ The arcade's player bar shows a Mute button for games that listen for it, and the player's choice
332
+ carries over to the next game that does. Your frame is on another site, so the bar can't silence
333
+ it; the game does. Say you listen once at startup (protocol `mute/1`), then follow what the bar
334
+ sends, before and after the first click:
335
+
336
+ ```js
337
+ // Call once at startup. setMuted(true | false) runs now (with the player's choice) and on every press.
338
+ function listenForArcadeMute(setMuted) {
339
+ if (window.parent === window) return; // not in a frame: nothing to listen to
340
+ addEventListener("message", (e) => {
341
+ const m = e.data;
342
+ if (e.source === window.parent && m?.arcade === "mute/1" && typeof m.muted === "boolean")
343
+ setMuted(m.muted);
344
+ });
345
+ window.parent.postMessage({ arcade: "mute/1", op: "ready" }, "*");
346
+ }
347
+ listenForArcadeMute((muted) => { master.gain.value = muted ? 0 : 1; }); // your master GainNode
348
+ ```
349
+
350
+ Route every sound through one master `GainNode` and mute that. Don't `suspend()` the
351
+ `AudioContext`: sounds scheduled while it's suspended all play at once when it resumes. Mute
352
+ `<audio>` and `<video>` elements with their `muted` property. Keep your own mute key too: it's the
353
+ only one players have outside the arcade.
354
+
355
+ ## Profile saves
356
+
357
+ A game can save progress to the player's arcade profile, so it follows them to any device. Games
358
+ with `"profile_saves": true` in arcade.json get a "Saves progress" badge and a Browse filter.
359
+
360
+ Your frame can't reach the arcade (rule 3), so the arcade page around it does the saving. Copy
361
+ `arcade-saves.js` from this skill's folder into your game and use it:
362
+
363
+ ```js
364
+ import { createSaves } from "./arcade-saves.js";
365
+ const saves = createSaves({ key: "my-game:save:v1" }); // one key per save format
366
+ let state = sanitize(await saves.load()); // once at startup, before any save; null the first time
367
+ saves.save(state); // later, at a meaningful moment
368
+ ```
369
+
370
+ - **Signed in, the profile is the truth.** `load()` returns the profile's save, and `save()`
371
+ sends it there through the arcade page: at most one write every 10 seconds (the latest), and
372
+ one more when the page is hidden or closed. Every save also lands in `localStorage`.
373
+ - **Signed out, the device copy is all there is.** The same goes for `arcade dev` and the frame
374
+ harness, where `load()` stops waiting for an arcade page after 1.5 s (on the arcade itself it
375
+ waits up to 8 s). Progress made signed out moves up once, into a profile that has nothing saved
376
+ for this game yet. It never replaces a profile's save, and on a shared computer one signed-in
377
+ player's progress never moves into another's profile.
378
+ - `saves.signedIn` is true after `load()` when saves reach the profile. It's there for an
379
+ optional "Sign in to keep your progress" line on your title screen.
380
+ - **One slot per player per game, with one entry per key.** Your versions share the slot, and so
381
+ does a regen you make or pick as main. A regen someone else made plays from the device until
382
+ you pick it as main. Forks get their own slot. Since each key keeps its own entry, a player can open an
383
+ old version or another model's regen from the game's history and still find their progress in
384
+ the main one.
385
+ - Under the hood it's a small postMessage protocol (`saves/1`) that the arcade answers only for
386
+ your game's own frame, and only for your game's slot. Use the helper instead of speaking it.
387
+
388
+ **A new save format, or a game that already saves to `localStorage`:** give the helper a new key
389
+ and list the old one in `from`. When the new key has nothing yet, `load()` hands you the save
390
+ under the first `from` key that has one, from the profile or this device, including a plain
391
+ `localStorage` save your old code wrote. `saves.loadedFrom` names the key it came from, so you
392
+ know which migration to run. Your first `save()` writes the new key. Never write an old key in
393
+ the new format: older versions still read it. Golden Hour Pier's existing players would keep
394
+ their catch like this:
395
+
396
+ ```js
397
+ const saves = createSaves({ key: "golden-hour-pier:save:v1", from: ["golden-hour-pier:v1"] });
398
+ let state = sanitize(await saves.load()); // the same shape, so the same sanitize
399
+ ```
400
+
401
+ The rules:
402
+
403
+ 1. **Set `profile_saves` only if the game really uses the helper.** It's your claim, like
404
+ `input.gamepad`. The arcade gives no slot to a build without it.
405
+ 2. **Save at meaningful moments:** the end of a run, a purchase, an unlock, a settings change.
406
+ Never every frame.
407
+ 3. **Keep saves small:** well under 64 KB for the whole slot (every key's entry counts), with no
408
+ images, blobs, or logs. A player has 512 KB across every game. A save past either cap stays
409
+ on the device.
410
+ 4. **Version the save and never trust it.** Include `v`. Sanitize every field on load: clamp
411
+ numbers, drop ids you don't know, and fall back to defaults, as Golden Hour Pier's
412
+ `src/save.js` does. Migrate anything old instead of resetting it. When the shape changes,
413
+ change the key (above). If `load()` ever hands you a save newer than your code understands, play from
414
+ defaults and don't `save()` over it that session.
415
+ 5. **Work signed out.** Never block play on signing in.
416
+ 6. **Nothing secret or personal goes in a save:** no names, emails, or tokens.
417
+
418
+ Under `arcade dev`, check that a reload restores progress from the device copy, and that a
419
+ corrupted save (edit it in devtools) resets cleanly. The profile path only runs on the arcade, so
420
+ after publishing, play signed in, reload, and check that your progress came back.
421
+
422
+ ## Leaderboards
423
+
424
+ A game can post scores to global leaderboards. The game page shows each board's top 10 under
425
+ the game, and where the player stands. List up to 4 boards under `"leaderboards"` in arcade.json:
426
+
427
+ ```json
428
+ "leaderboards": [
429
+ { "id": "best-run", "label": "Best run", "max": 5000000 },
430
+ { "id": "world-1", "label": "World 1 time", "order": "asc", "format": "time_ms",
431
+ "min": 95000, "max": 3600000 }
432
+ ]
433
+ ```
434
+
435
+ - `id`: lowercase letters, digits, and hyphens, up to 32. It's permanent: if the scoring rules
436
+ change, use a new id.
437
+ - `label` (up to 40 characters) is what the game page shows.
438
+ - `order`: `"desc"` (higher is better, the default) or `"asc"` (lower is better, for times).
439
+ - `format`: `"points"` (the default) shows 1,240, `"time_ms"` shows 1:02.345, and `"distance_m"`
440
+ shows 1,240 m.
441
+ - `max` is required and `min` defaults to 0. The arcade refuses any whole number outside them, so
442
+ they're the board's only plausibility check. Set `max` near what a great player could reach (2 to
443
+ 3 times your simulation's best), not "infinity". On an `asc` board, `min` is the cap that
444
+ matters: the fastest a perfect run could be.
445
+
446
+ Boards are per game, not per level. A 60-level puzzle game posts a total (stars, or a world's
447
+ total time), not 60 boards. Copy `arcade-scores.js` from this skill's folder into your game:
448
+
449
+ ```js
450
+ import { createScores, formatScore } from "./arcade-scores.js";
451
+ const scores = createScores({ boards: LEADERBOARDS }); // the same array as arcade.json
452
+ scores.submit("best-run", runScore).then(showResult); // once per finished run
453
+ scores.best("best-run").then(({ best, rank }) => ...); // for the title screen
454
+ ```
455
+
456
+ - `submit` resolves `{ accepted, signedIn, best, rank, newBest, reason }`. Accepted, `best` and
457
+ `rank` are the player's leaderboard best and its rank (ties share one). Otherwise `best` is this
458
+ device's best, and `reason` says why: `signed_out`, `offline` (not on the arcade, or no answer
459
+ in 10 s), `not_enabled`, `unknown_board`, `invalid`, `rate_limited`, or `error`.
460
+ - Signed-out scores stay on the device and never move up later, since on a shared computer they
461
+ could be someone else's.
462
+
463
+ The rules:
464
+
465
+ 1. **Post once per finished run, never per frame, and never block the restart on it.** The arcade
466
+ takes one submit per player per board every 2 seconds.
467
+ 2. **Show the run's value at once.** When `submit` resolves, add one line: `New best! #12`,
468
+ `Best 1,240 · #37`, `Sign in on the arcade to post your score` (for `signed_out`), or
469
+ `Best on this device: 1,240` (anything else). Never show error text. On the title screen,
470
+ `Your best 1,240 · #37` from `best()`.
471
+ 3. **No in-game global top 10.** The game page already shows it.
472
+ 4. **Only builds the owner stands behind post.** Like profile saves: your own builds, or a regen
473
+ you pick as main. `arcade regen` leaves `leaderboards` out, because the scoring code doesn't come
474
+ along. Forks are new games with boards of their own.
475
+
476
+ Off the arcade (`arcade dev`, the frame harness), `submit` answers `offline` and keeps a device
477
+ best, so build and test against that. After publishing, finish a run signed in and check that the
478
+ game page's leaderboard shows it.
479
+
480
+ ## Verify by actually playing
481
+
482
+ - Build, then run `arcade dev` in the game folder. It serves `play.root` at
483
+ `http://localhost:5173/` (this computer only) with the arcade's CSP, so outside calls fail right
484
+ away. If Vite already has port 5173, pass `--port 5174`. Play it end to
485
+ end: title, play, lose, restart. Dev isn't the frame (see rules 2 and 3). It has no sandbox and
486
+ no `frame-ancestors`, so anything that breaks only in the frame shows up only in the harness.
487
+ - Drive it with Playwright. Click the title, hold keys, move the mouse, fire. Collect console
488
+ errors and page errors, and record every request.
489
+ - Add a small debug hook, like `window.__game` with state, fps, the player, and a spawn function,
490
+ so scripts can set up exact situations. A **bot hit test** freezes a target at several ranges and
491
+ fires N shots, and every shot should hit. In Last Signal it caught a real bug: the cooldown went
492
+ negative while idle, so every tap fired two shots.
493
+ - Add an **autopilot** (`?autopilot`) that plays the game by itself. It gives you repeatable soak
494
+ runs and demo footage. If your media is bot-driven, say so in `provenance.notes`.
495
+ - **Keep seeded runs repeatable.** If you seed gameplay randomness (`?seed=N`), give particles,
496
+ sparks, blinks and audio their own generator, or cosmetic effects will drift the game within a
497
+ second. Prove it: run the same seed twice and compare the game state.
498
+ - **Stage only states a player can reach.** A debug hook makes it easy to screenshot something no
499
+ player could ever see, like a build menu over an occupied pad. Set up shots through the real
500
+ input path.
501
+ - **Capture in game time.** When you record frame by frame, banners and CSS animations driven by
502
+ `setTimeout` or wall-clock time come and go depending on capture speed. Drive them from the game
503
+ clock.
504
+ - **Test with the sound off.** You are usually testing on someone's computer. Launch browsers with
505
+ `--mute-audio` and check audio by analysis instead: the AudioContext state, plus analyser RMS and
506
+ peak levels. The flag is Chromium-only, so in WebKit or Firefox don't make the first gesture that
507
+ starts audio.
508
+ - **Let a script prove the level.** Keep the simulation free of browser APIs so Node can run it.
509
+ Keepfall's solver ran the game's own physics to prove the climb beatable, even with the hero's
510
+ jump cut by 12%. That is stronger evidence than any number of soak runs.
511
+ - **Check your arcade.json before publishing.** `arcade publish --dry-run` validates it against
512
+ the server. Offline, import `parseArcadeJson` from `@ea/format` and pass it the parsed object,
513
+ not the file's text.
514
+ - **2D canvas games** have their own traps: cap the backing store on hi-DPI screens, overlap tiles
515
+ by a pixel to hide seams, cache sprites per zoom level, and draw a facade over any secret room
516
+ that a side view would reveal. Test that one wall can't be climbed forever with wall jumps.
517
+ - **Frame harness.** Open the harness page below as a local file, so the game is cross-origin the
518
+ way it is on the arcade, and drive it through `page.frameLocator("iframe")`. Check that it
519
+ starts, that the AudioContext state is `running`, that lock or its fallback works, and that pause
520
+ works. Check that it looks right at about 640x360 and at full screen.
521
+ - Look at what you made. Open the screenshots and watch the recording, or a contact sheet of it.
522
+ Whiteouts and clipped text only showed up in footage.
523
+ - Say what automation can't prove. Automated browsers usually refuse pointer lock (they test the
524
+ fallback), they can't hear the mix, and they don't play like people. Ask the human to play for
525
+ two minutes and tell you what felt worst.
526
+
527
+ Capture media from real gameplay. The rules for media and publishing are in `arcade-publishing`.
528
+
529
+ ## Worked examples
530
+
531
+ A Vite and three.js game. Install three from npm so it gets bundled, not loaded from a CDN:
532
+
533
+ ```js
534
+ // vite.config.js
535
+ export default { base: "./", build: { outDir: "dist" } };
536
+ // arcade.json: "play": { "root": "dist" }, and run `arcade dev` after `npm run build`
537
+ ```
538
+
539
+ Start or resume on a click or key, and survive a refused lock:
540
+
541
+ ```js
542
+ let audio;
543
+ function onGesture() { // title and pause screens both call this
544
+ audio ??= new AudioContext();
545
+ audio.resume();
546
+ canvas.requestPointerLock?.()?.catch?.(useAbsoluteMouse); // Chrome's promise rejects on refusal
547
+ startOrResume();
548
+ }
549
+ overlay.addEventListener("click", onGesture);
550
+ addEventListener("keydown", (e) => { if (!playing && e.key !== "Escape") onGesture(); });
551
+ document.addEventListener("pointerlockerror", useAbsoluteMouse);
552
+ document.addEventListener("pointerlockchange", () => {
553
+ if (!document.pointerLockElement && playing) pause();
554
+ });
555
+ ```
556
+
557
+ A frame harness. Keep it outside the game folder (say, `../harness/frame.html`), because everything
558
+ in the folder is published:
559
+
560
+ ```html
561
+ <div style="width:640px;aspect-ratio:16/9;resize:both;overflow:hidden">
562
+ <iframe src="http://localhost:5173/" style="width:100%;height:100%;border:0"
563
+ sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-orientation-lock"
564
+ allow="fullscreen; gamepad; autoplay; accelerometer; gyroscope; magnetometer"
565
+ allowfullscreen></iframe>
566
+ </div>
567
+ ```
568
+
569
+ Check for outside requests and errors in a Playwright run, directly or through the harness:
570
+
571
+ ```js
572
+ const gameUrl = "http://localhost:5173/";
573
+ const origin = new URL(gameUrl).origin;
574
+ const outside = [], errors = [];
575
+ page.on("request", (r) => {
576
+ const u = r.url();
577
+ if (!u.startsWith(origin) && !/^(data|blob|file):/.test(u)) outside.push(u);
578
+ });
579
+ page.on("websocket", (ws) => outside.push(ws.url()));
580
+ page.on("pageerror", (e) => errors.push(e.message));
581
+ page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
582
+ // play, then expect both arrays to be empty
583
+ ```
584
+
585
+ An economy sim that plays the game's own economy module with three simulated players (see "Prove
586
+ the pacing" under Long-term fun). `record` collects each run for the report:
587
+
588
+ ```js
589
+ // tools/economy-sim.mjs. Run: node tools/economy-sim.mjs
590
+ import { freshSave, playRun, buyNext, rewards, seeded, RUNS_PER_SESSION } from "../src/economy.js";
591
+ const PLAYERS = { slow: 0.45, typical: 0.65, expert: 0.9 }; // where each player's skill levels off
592
+ for (const [name, top] of Object.entries(PLAYERS)) for (let seed = 1; seed <= 20; seed++) {
593
+ const save = freshSave(), rng = seeded(seed), firstSeen = new Map();
594
+ for (let session = 1; session <= 150; session++) {
595
+ const skill = top * (1 - 0.6 * Math.exp(-session / 6)); // learns fast, then plateaus
596
+ for (let r = 0; r < RUNS_PER_SESSION; r++) playRun(save, skill, rng);
597
+ while (buyNext(save)); // a sensible buyer, not a perfect one
598
+ for (const id of rewards(save)) if (!firstSeen.has(id)) firstSeen.set(id, session);
599
+ }
600
+ record(name, seed, firstSeen); // then print medians and spread, longest gaps, idle money, pass or fail
601
+ }
602
+ ```