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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/cli.js +22334 -0
- package/package.json +37 -6
- package/skills/arcade-building-games/SKILL.md +602 -0
- package/skills/arcade-building-games/arcade-saves.js +243 -0
- package/skills/arcade-building-games/arcade-scores.js +214 -0
- package/skills/arcade-getting-started/SKILL.md +88 -0
- package/skills/arcade-publishing/SKILL.md +163 -0
- package/skills/arcade-remix-and-blend/SKILL.md +115 -0
package/package.json
CHANGED
|
@@ -1,12 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evolutionary-arcade",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Evolutionary Arcade
|
|
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
|
-
"
|
|
12
|
+
"dist",
|
|
13
|
+
"skills",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"LICENSE"
|
|
7
17
|
],
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
|
|
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
|
+
```
|