@myvillage/cli 2.6.1 → 2.8.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.6.1",
3
+ "version": "2.8.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,434 @@
1
+ // MyVillage Game Bridge — the "mvgame" protocol (v2).
2
+ //
3
+ // ONE contract for every MyVillage game, on every surface:
4
+ // - muni mobile app (React Native WebView)
5
+ // - the web (mvp-video-games, in an iframe)
6
+ // - MVP Studio preview and standalone `npm run dev`
7
+ //
8
+ // Two calls every game makes:
9
+ // MyVillageGame.ready() // once, when your game is interactive
10
+ // MyVillageGame.complete({ score }) // when the game is finished / won / over
11
+ //
12
+ // One the host ALWAYS provides, but you can trigger too:
13
+ // MyVillageGame.exit() // leave the game
14
+ //
15
+ // Real-time multiplayer (optional) — see "Multiplayer" below:
16
+ // const mp = await MyVillageGame.multiplayer({ arena, game: 'my-game' });
17
+ // await mp.quickMatch(); // or mp.host() / mp.join(code)
18
+ // const net = await mp.connect(); // live match (src/myvillage-realtime.js)
19
+ //
20
+ // The host always renders an Exit control, so a player can leave ANY game at
21
+ // any time even if the game never calls exit() itself.
22
+ //
23
+ // This file is part of the platform. Do not edit it — `myvillage` CLI updates
24
+ // replace it.
25
+
26
+ const PROTOCOL = 'mvgame';
27
+ const HOST_PROTOCOL = 'mvgame-host';
28
+ const VERSION = 2;
29
+
30
+ const hasWindow = typeof window !== 'undefined';
31
+ const inReactNative = hasWindow && !!window.ReactNativeWebView;
32
+ const inIframe = hasWindow && window.parent && window.parent !== window;
33
+ const standalone = hasWindow && !inReactNative && !inIframe;
34
+
35
+ function now() {
36
+ return typeof performance !== 'undefined' ? performance.now() : Date.now();
37
+ }
38
+
39
+ function post(message) {
40
+ const payload = { source: PROTOCOL, version: VERSION, ...message };
41
+ const json = JSON.stringify(payload);
42
+ try {
43
+ // React Native WebView (muni mobile app)
44
+ if (inReactNative) window.ReactNativeWebView.postMessage(json);
45
+ // Iframe parent (mvp-video-games web host)
46
+ if (inIframe) {
47
+ window.parent.postMessage(payload, '*');
48
+ // The web player also listens for the older Unity WebGL names.
49
+ if (message.type === 'ready') window.parent.postMessage({ type: 'GAME_STARTED' }, '*');
50
+ if (message.type === 'complete') window.parent.postMessage({ type: 'GAME_COMPLETED', score: message.score }, '*');
51
+ }
52
+ if (standalone) {
53
+ // MVP Studio's preview relays window messages whose type starts with "mvgame".
54
+ window.postMessage({ ...payload, type: PROTOCOL + ':' + message.type }, '*');
55
+ console.info('[mvgame] (no host attached) ' + json);
56
+ }
57
+ } catch (e) {
58
+ console.warn('[mvgame] postMessage failed', e);
59
+ }
60
+ }
61
+
62
+ // ── Host context ────────────────────────────────────────────────────────────
63
+ // Hosts that support multiplayer hand the game a context:
64
+ // { apiBase, game, playToken, platform, player: { displayName } }
65
+ // either up front (window.__MYVILLAGE_HOST__) or in reply to our "hello":
66
+ // { source: 'mvgame-host', type: 'init', context: {...} }
67
+
68
+ let hostContext = null;
69
+ const hostWaiters = [];
70
+
71
+ function acceptHost(ctx) {
72
+ if (!ctx || typeof ctx !== 'object' || hostContext) return;
73
+ if (typeof ctx.apiBase !== 'string' || typeof ctx.playToken !== 'string' || typeof ctx.game !== 'string') return;
74
+ hostContext = {
75
+ apiBase: ctx.apiBase.replace(/\/+$/, ''),
76
+ game: ctx.game,
77
+ playToken: ctx.playToken,
78
+ platform: typeof ctx.platform === 'string' ? ctx.platform : 'webgl',
79
+ player: { displayName: (ctx.player && ctx.player.displayName) || 'Player' },
80
+ };
81
+ while (hostWaiters.length) hostWaiters.shift()(hostContext);
82
+ }
83
+
84
+ function onHostMessage(event) {
85
+ let data = event && event.data;
86
+ if (typeof data === 'string') {
87
+ try {
88
+ data = JSON.parse(data);
89
+ } catch (_) {
90
+ return;
91
+ }
92
+ }
93
+ if (data && data.source === HOST_PROTOCOL && data.type === 'init') acceptHost(data.context || data);
94
+ }
95
+
96
+ if (hasWindow) {
97
+ if (window.__MYVILLAGE_HOST__) acceptHost(window.__MYVILLAGE_HOST__);
98
+ window.addEventListener('message', onHostMessage);
99
+ // React Native WebView on Android delivers postMessage on document.
100
+ if (typeof document !== 'undefined') document.addEventListener('message', onHostMessage);
101
+ }
102
+
103
+ /** Ask the host for its context; resolves null when there's no capable host. */
104
+ function requestHostContext(timeoutMs) {
105
+ if (hostContext) return Promise.resolve(hostContext);
106
+ return new Promise((resolve) => {
107
+ let done = false;
108
+ const finish = (ctx) => {
109
+ if (done) return;
110
+ done = true;
111
+ clearInterval(retry);
112
+ clearTimeout(timer);
113
+ resolve(ctx);
114
+ };
115
+ hostWaiters.push(finish);
116
+ const hello = () => post({ type: 'hello', wants: ['multiplayer'] });
117
+ hello();
118
+ const retry = setInterval(hello, 500);
119
+ const timer = setTimeout(() => finish(null), timeoutMs);
120
+ });
121
+ }
122
+
123
+ // ── Multiplayer ─────────────────────────────────────────────────────────────
124
+ //
125
+ // The platform does the plumbing; your game owns the lobby UI. A typical flow:
126
+ //
127
+ // const mp = await MyVillageGame.multiplayer({ arena, game: 'my-game' });
128
+ // const match = await mp.quickMatch(); // or mp.host({ visibility: 'village' }) / mp.join('K7PQ2M')
129
+ // showCode(match.code);
130
+ // const stop = mp.watch((m) => renderPlayers(m.players));
131
+ // const net = await mp.connect(); // ArenaClient from src/myvillage-realtime.js
132
+ // // each frame: net.controls = {...}; net.update(dt); draw(net.view, net.others());
133
+ //
134
+ // Inside the muni app / web player, matches go through the MyVillage platform
135
+ // (real players, village-only quick match, MVT on completion). Standalone —
136
+ // `npm run dev` or the Studio preview — there is no platform session, so it
137
+ // runs in DEV MODE against the public staging match server: open two browser
138
+ // tabs and use the same code (or both press quick match) to play yourself.
139
+
140
+ const DEFAULT_DEV_SERVER = 'https://myvillage-realtime-staging.myvillage-realtime.workers.dev';
141
+ const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
142
+
143
+ export class MultiplayerError extends Error {
144
+ constructor(code, message, status) {
145
+ super(message);
146
+ this.name = 'MultiplayerError';
147
+ this.code = code;
148
+ this.status = status;
149
+ }
150
+ }
151
+
152
+ function randomCode() {
153
+ let s = '';
154
+ for (let i = 0; i < 6; i++) s += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
155
+ return s;
156
+ }
157
+
158
+ function normalizeCode(code) {
159
+ return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
160
+ }
161
+
162
+ function devPlayerId() {
163
+ const key = 'mvgame.devPlayerId';
164
+ try {
165
+ let id = localStorage.getItem(key);
166
+ if (!id) {
167
+ id = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
168
+ localStorage.setItem(key, id);
169
+ }
170
+ return id;
171
+ } catch (_) {
172
+ return 'dev-' + Math.random().toString(36).slice(2, 12);
173
+ }
174
+ }
175
+
176
+ class Multiplayer {
177
+ constructor(ctx, options) {
178
+ this.mode = ctx ? 'platform' : 'dev';
179
+ this._ctx = ctx;
180
+ this._opts = options;
181
+ this._net = null;
182
+ this._ticket = null;
183
+ /** The current match (see MatchView in the platform docs), or null. */
184
+ this.match = null;
185
+ this.me = { displayName: ctx ? ctx.player.displayName : options.name || 'Player ' + randomCode().slice(0, 3) };
186
+ }
187
+
188
+ // ── lobby plumbing ──
189
+
190
+ /** Host a match. visibility: 'private' (code only) or 'village' (listed + quick match). */
191
+ async host({ visibility = 'private', maxPlayers, lobby } = {}) {
192
+ if (this.mode === 'dev') return this._devMatch(randomCode(), true, lobby);
193
+ return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches`, { visibility, maxPlayers, lobby, platform: this._ctx.platform }));
194
+ }
195
+
196
+ /** Join a match by its code (forgiving: "abc-234" works). */
197
+ async join(code) {
198
+ if (this.mode === 'dev') return this._devMatch(normalizeCode(code), false);
199
+ return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches/join`, { code, platform: this._ctx.platform }));
200
+ }
201
+
202
+ /** Join the fullest open match in the player's villages, or host one. */
203
+ async quickMatch({ lobby } = {}) {
204
+ // Finished match rooms can't be reused, so dev quick match rotates rooms
205
+ // every 10 minutes; tabs that press it within the same window meet.
206
+ if (this.mode === 'dev') return this._devMatch('QUICK' + Math.floor(Date.now() / 600000).toString(36).toUpperCase(), false, lobby);
207
+ return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches/quick`, { platform: this._ctx.platform, lobby }));
208
+ }
209
+
210
+ /** Open village matches the player can join, plus matches they're in. */
211
+ async list() {
212
+ if (this.mode === 'dev') return this.match ? [this.match] : [];
213
+ return (await this._api('GET', `/api/v1/games/${this._game()}/matches`)).matches;
214
+ }
215
+
216
+ /** Re-read the current match (players, host, lobby state). */
217
+ async refresh() {
218
+ if (!this.match || this.mode === 'dev') return this.match;
219
+ this.match = (await this._api('GET', `/api/v1/matches/${this.match.id}`)).match;
220
+ return this.match;
221
+ }
222
+
223
+ /**
224
+ * Call `onChange(match)` whenever the match changes (polls every
225
+ * `intervalMs`). Returns a function that stops watching.
226
+ */
227
+ watch(onChange, intervalMs = 2000) {
228
+ let last = '';
229
+ let stopped = false;
230
+ const tick = async () => {
231
+ if (stopped || !this.match) return;
232
+ try {
233
+ const m = await this.refresh();
234
+ const key = JSON.stringify(m);
235
+ if (key !== last) {
236
+ last = key;
237
+ onChange(m);
238
+ }
239
+ } catch (e) {
240
+ console.warn('[mvgame] match refresh failed', e);
241
+ }
242
+ };
243
+ tick();
244
+ const timer = this.mode === 'dev' ? null : setInterval(tick, intervalMs);
245
+ return () => {
246
+ stopped = true;
247
+ if (timer) clearInterval(timer);
248
+ };
249
+ }
250
+
251
+ /** Host only: store game-defined lobby state (teams, map pick, ...). Max 4 KB. */
252
+ async setLobby(lobby) {
253
+ return this._update({ lobby });
254
+ }
255
+
256
+ /** Host only: stop new players joining. */
257
+ async lock() {
258
+ return this._update({ status: 'in_progress' });
259
+ }
260
+
261
+ /** Host only: let players join again. */
262
+ async unlock() {
263
+ return this._update({ status: 'open' });
264
+ }
265
+
266
+ /** Host only: cancel the match for everyone. */
267
+ async cancel() {
268
+ return this._update({ status: 'cancelled' });
269
+ }
270
+
271
+ /** Leave the match (and disconnect). The host role passes to another player. */
272
+ async leave() {
273
+ const match = this.match;
274
+ if (this._net) this._net.close();
275
+ this._net = null;
276
+ this._ticket = null;
277
+ this.match = null;
278
+ if (match && this.mode === 'platform') await this._api('POST', `/api/v1/matches/${match.id}/leave`);
279
+ }
280
+
281
+ // ── live match ──
282
+
283
+ /**
284
+ * Connect to the live match and return a connected ArenaClient
285
+ * (src/myvillage-realtime.js). Call again after a disconnect to rejoin.
286
+ */
287
+ async connect() {
288
+ if (!this.match) throw new MultiplayerError('no_match', 'Host, join or quick match first.');
289
+ const ticket = await this._freshTicket();
290
+ const { ArenaClient } = await import('./myvillage-realtime.js');
291
+ const net = new ArenaClient({ wsUrl: ticket.wsUrl, ticket: ticket.ticket });
292
+ await net.connect();
293
+ this._ticket = null; // single use: a reconnect fetches a fresh one
294
+ this._net = net;
295
+ return net;
296
+ }
297
+
298
+ // ── internals ──
299
+
300
+ _game() {
301
+ return encodeURIComponent(this._ctx.game);
302
+ }
303
+
304
+ async _api(method, path, body) {
305
+ const res = await fetch(this._ctx.apiBase + path, {
306
+ method,
307
+ headers: { Authorization: 'Bearer ' + this._ctx.playToken, 'Content-Type': 'application/json' },
308
+ body: body === undefined ? undefined : JSON.stringify(body),
309
+ });
310
+ let data = {};
311
+ try {
312
+ data = await res.json();
313
+ } catch (_) {
314
+ // non-JSON error page
315
+ }
316
+ if (!res.ok) throw new MultiplayerError(data.code || 'http_' + res.status, data.error || 'Request failed (' + res.status + ')', res.status);
317
+ return data;
318
+ }
319
+
320
+ _enter(result) {
321
+ this.match = result.match;
322
+ this._ticket = result.realtime;
323
+ return this.match;
324
+ }
325
+
326
+ async _update(body) {
327
+ if (!this.match) throw new MultiplayerError('no_match', 'Not in a match.');
328
+ if (this.mode === 'dev') {
329
+ if (body.lobby !== undefined) this.match = { ...this.match, lobby: body.lobby };
330
+ return this.match;
331
+ }
332
+ this.match = (await this._api('PATCH', `/api/v1/matches/${this.match.id}`, body)).match;
333
+ return this.match;
334
+ }
335
+
336
+ async _freshTicket() {
337
+ if (this._ticket && new Date(this._ticket.expiresAt).getTime() - Date.now() > 30000) return this._ticket;
338
+ if (this.mode === 'platform') {
339
+ return (await this._api('POST', `/api/v1/matches/${this.match.id}/ticket`)).realtime;
340
+ }
341
+ const server = (this._opts.devServer || DEFAULT_DEV_SERVER).replace(/\/+$/, '');
342
+ const res = await fetch(server + '/dev/ticket', {
343
+ method: 'POST',
344
+ headers: { 'Content-Type': 'application/json' },
345
+ body: JSON.stringify({
346
+ matchId: this.match.id,
347
+ name: this.me.displayName,
348
+ villagerId: devPlayerId(),
349
+ game: this._opts.game || 'dev-game',
350
+ arena: this._opts.arena,
351
+ }),
352
+ });
353
+ if (!res.ok) throw new MultiplayerError('dev_server_unavailable', 'Dev match server unavailable (' + res.status + ')', res.status);
354
+ const t = await res.json();
355
+ return { ticket: t.ticket, wsUrl: t.wsUrl, expiresAt: new Date(Date.now() + 240000).toISOString() };
356
+ }
357
+
358
+ _devMatch(code, isHost, lobby) {
359
+ const arena = this._opts.arena || {};
360
+ const gameKey = String(this._opts.game || 'dev-game').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40);
361
+ this.match = {
362
+ id: 'dev-' + gameKey + '-' + code,
363
+ code,
364
+ game: gameKey,
365
+ status: 'OPEN',
366
+ visibility: 'PRIVATE',
367
+ minPlayers: arena.minPlayers || 2,
368
+ maxPlayers: arena.maxPlayers || 8,
369
+ playerCount: 1,
370
+ lobby: lobby || null,
371
+ isPlaytest: true,
372
+ dev: true,
373
+ you: { playerId: 'me', isHost, inMatch: true },
374
+ // In dev mode other players show up in the live match roster once connected.
375
+ players: [{ playerId: 'me', displayName: this.me.displayName, isHost, isYou: true }],
376
+ };
377
+ return this.match;
378
+ }
379
+ }
380
+
381
+ let readySent = false;
382
+ let startedAt = now();
383
+
384
+ export const MyVillageGame = {
385
+ /** Call once when the game has loaded and is interactive. */
386
+ ready() {
387
+ if (readySent) return;
388
+ readySent = true;
389
+ startedAt = now();
390
+ post({ type: 'ready' });
391
+ },
392
+
393
+ /**
394
+ * Call when the game is finished. `result` is optional:
395
+ * { score?: number, rewards?: number, won?: boolean, custom?: object }
396
+ * `durationMs` is added automatically.
397
+ */
398
+ complete(result = {}) {
399
+ post({ type: 'complete', durationMs: Math.round(now() - startedAt), ...result });
400
+ },
401
+
402
+ /** Request to leave the game. The host also always offers an Exit control. */
403
+ exit() {
404
+ post({ type: 'exit' });
405
+ },
406
+
407
+ /**
408
+ * The host's context ({ game, platform, player: { displayName } }) or null
409
+ * when running standalone. Waits briefly for the host to answer.
410
+ */
411
+ async host() {
412
+ const ctx = await requestHostContext(standalone ? 300 : 3000);
413
+ return ctx && { game: ctx.game, platform: ctx.platform, player: { ...ctx.player } };
414
+ },
415
+
416
+ /**
417
+ * Real-time multiplayer. Options:
418
+ * arena — this game's arena config (src/arena.json); used for dev-mode matches
419
+ * game — a short id for dev-mode match rooms (e.g. your game's slug)
420
+ * name — dev-mode display name (platform mode uses the player's name)
421
+ * devServer — dev-mode match server (defaults to the MyVillage staging server)
422
+ */
423
+ async multiplayer(options = {}) {
424
+ const ctx = await requestHostContext(standalone ? 300 : 3000);
425
+ return new Multiplayer(ctx, options);
426
+ },
427
+ };
428
+
429
+ // Expose globally too, for non-module games and quick debugging.
430
+ if (hasWindow) {
431
+ window.MyVillageGame = MyVillageGame;
432
+ }
433
+
434
+ export default MyVillageGame;
@@ -0,0 +1,93 @@
1
+ # AGENTS.md — working on this MyVillage arena game with an AI agent
2
+
3
+ **{{NAME}}** is a real-time **multiplayer** MyVillage game: a first-person arena built with **Three.js** and Vite. Players meet in a lobby, then play a live match on MyVillage's match servers. It runs inside the MyVillage host (the muni mobile app or the web player), and standalone in a browser.
4
+
5
+ When editing: make the requested change, keep the **multiplayer contract** intact (below), and keep `npm run build` working.
6
+
7
+ ## How multiplayer works here (read first)
8
+
9
+ - **The match server is the referee.** Clients only send controls (move, look, fire). The server moves every player, decides every hit, keeps score and runs the clock. The game *draws* what the server reports; it never decides outcomes.
10
+ - **The rules are data:** `src/arena.json` (mode, player counts, duration, speed, damage, the map). `myvillage deploy` sends it to the platform, and every match of this game runs with it.
11
+ - **The lobby is this game's own code** (`src/lobby.js`). The platform only provides the plumbing it calls. Restyle it freely.
12
+
13
+ So, roughly:
14
+
15
+ | You want to… | Change |
16
+ |---|---|
17
+ | Restyle or rework the lobby: teams, ready check, list open matches | `src/lobby.js` (+ CSS in `index.html`) |
18
+ | Change how the arena looks: models, colors, effects, sounds, HUD | `src/arena-game.js`, `index.html` |
19
+ | Change the rules: tag vs elimination, players, match length, speed, damage, respawn | `src/arena.json` |
20
+ | Change the map | `src/arena.json` → `map` (the walls and spawns the server uses). `arena-game.js` draws it from the same data. |
21
+ | Add new mechanics the server must judge (jumping, power-ups, grenades, new weapons, vertical aiming) | **Not possible in game code yet.** The match server's rules are fixed per mode; tell the user, and offer a cosmetic version or a config change. |
22
+
23
+ ## File map
24
+
25
+ | File | Purpose |
26
+ |------|---------|
27
+ | `src/main.js` | Boot: lobby → `mp.connect()` → match → results → lobby. |
28
+ | `src/lobby.js` | **The lobby screen** (Quick match / Host / Join with code). Yours to change. |
29
+ | `src/arena-game.js` | Three.js scene, avatars, shot tracers, HUD, keyboard/mouse/touch input. |
30
+ | `src/arena.json` | **Match rules and map**, shared with the match server. |
31
+ | `src/myvillage-game.js` | Platform bridge and multiplayer plumbing. **Do not edit.** |
32
+ | `src/myvillage-realtime.js` | Match netcode (prediction, interpolation). **Do not edit.** |
33
+ | `index.html` | Page shell + all CSS. |
34
+ | `assets/` | Cover art shipped on deploy. |
35
+
36
+ ## Multiplayer API (`src/myvillage-game.js`)
37
+
38
+ ```js
39
+ const mp = await MyVillageGame.multiplayer({ arena, game: '{{SLUG}}' });
40
+ mp.mode // 'platform' (in the app / web player) or 'dev' (standalone)
41
+ mp.me.displayName
42
+ await mp.quickMatch() // join the fullest open match in the player's village, or host one
43
+ await mp.host({ visibility }) // 'private' (code only) or 'village'
44
+ await mp.join(code) // join by code ("abc-234" works)
45
+ await mp.list() // open village matches + the player's matches (lobby browser)
46
+ mp.match // { id, code, status, players: [{ playerId, displayName, isHost, isYou }], lobby, minPlayers, maxPlayers, you: { isHost } }
47
+ const stop = mp.watch(cb) // cb(match) whenever players / lobby change
48
+ await mp.setLobby({ ... }) // host only: game-defined lobby state (≤ 4 KB), e.g. teams or map pick
49
+ await mp.lock() / unlock() / cancel() // host only
50
+ await mp.leave()
51
+ const net = await mp.connect() // live match → ArenaClient
52
+ ```
53
+
54
+ The live match (`net`, an `ArenaClient`):
55
+
56
+ - Each frame: `net.controls = { mx, mz, yaw, pitch, fire }`, then `net.update(dt)`.
57
+ - Draw yourself at `net.view`: `{ x, z, yaw, hp, alive, it, score }`.
58
+ - Draw everyone else from `net.others()`: `[{ id, n: name, x, z, y: yaw, hp, a: alive, s: score, it }]`.
59
+ - Also available: `net.roster`, `net.phase` (`waiting` / `countdown` / `playing` / `ended`), `net.timeLeft`, `net.cfg`, `net.rttMs`.
60
+ - `net.on('event', e => …)` events: `shot`, `elim`, `tag`, `spawn`, `join`, `leave`.
61
+ - `net.on('end', e => …)` gives the ranked results. `net.on('close', …)` fires on disconnect; call `mp.connect()` again to rejoin.
62
+
63
+ A match starts by itself once `minPlayers` are connected. Until then players can run around and warm up.
64
+
65
+ ## The multiplayer contract — DO NOT BREAK
66
+
67
+ - Keep `MyVillageGame.ready()` in `src/main.js`.
68
+ - **Never send positions, hits or scores yourself.** Only `net.controls`. Anything else is ignored by the server, and it's the server's results that award MVT.
69
+ - **Don't call `MyVillageGame.complete()` when a match ends.** The platform records multiplayer results from the match server. Hosts treat `complete()` as "close the game", which would stop players from playing again.
70
+ - Players are identified only by display name and an in-match id. Don't ask for, show or store anything more about them.
71
+ - **Players only play with their own village.** The platform enforces it: codes only admit village-mates of the host, and quick match stays inside the player's villages. Don't build anything that suggests playing with strangers.
72
+ - Don't add an Exit button; the host provides one (top-left). The in-game **Leave** button just returns to the lobby.
73
+
74
+ ## Running it
75
+
76
+ npm install
77
+ npm run dev # standalone = DEV MODE: uses MyVillage's public test match server
78
+ npm run build # production build -> dist/ (must pass before deploy)
79
+ myvillage deploy # build, then upload + submit (also sends src/arena.json)
80
+
81
+ **Dev mode:** open the dev URL in **two browser tabs**. Either press Quick match in both, or Host in one and Join with its code in the other. That's how to test multiplayer alone. In dev mode there are no real accounts, no village matching and no MVT; in the app it all works for real.
82
+
83
+ ## Screen layout (phones)
84
+
85
+ - The game fills the screen edge to edge. Keep HUD inside the safe area with the CSS variables in `index.html` (`--hud-top`, `--safe-bottom`, `--safe-left`, `--safe-right`).
86
+ - Leave the **top-left corner** (`--exit-clear` wide) empty for the host's Exit button.
87
+ - Touch controls: left half of the screen = move stick, right half = drag to look, red button = fire.
88
+
89
+ ## Conventions
90
+
91
+ - Orientation is **{{ORIENTATION}}** (`package.json` → `myvillage.orientation`).
92
+ - Keep asset URLs relative (Vite `base: './'`).
93
+ - Cover art: `assets/thumbnail.png` (square) and `assets/banner.png`.
@@ -0,0 +1,36 @@
1
+ # {{NAME}}
2
+
3
+ {{DESCRIPTION}}
4
+
5
+ A real-time multiplayer arena for MyVillage. Players meet in the lobby, then play a live first-person match. The rules ship in `src/arena.json`: **elimination** (blasters) or **tag**, plus player counts, match length and the map.
6
+
7
+ ## Play it
8
+
9
+ ```bash
10
+ npm install
11
+ npm run dev
12
+ ```
13
+
14
+ Open the page in **two browser tabs** and press **Quick match** in both, or **Host** in one and **Join** with its code in the other.
15
+
16
+ - Desktop: click to aim, WASD to move, Q/E to turn, click or Space to fire.
17
+ - Phone: left thumb moves, right thumb looks, red button fires.
18
+
19
+ Running standalone uses MyVillage's public test match server ("dev mode"). Inside the MyVillage app, matches are real: village quick match, friends by code, and MVT for finished matches.
20
+
21
+ ## Make it yours
22
+
23
+ - **Lobby:** `src/lobby.js`
24
+ - **Look and HUD:** `src/arena-game.js` and `index.html`
25
+ - **Rules and map:** `src/arena.json`
26
+
27
+ See `AGENTS.md` for the full guide, including what the match server decides and what your code can change.
28
+
29
+ ## Deploy
30
+
31
+ ```bash
32
+ npm run build
33
+ myvillage deploy
34
+ ```
35
+
36
+ `deploy` also sends `src/arena.json`, so every match of your game runs with your rules.
@@ -0,0 +1,74 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <!-- orientation: {{ORIENTATION}} -->
7
+ <title>{{NAME}}</title>
8
+ <style>
9
+ /* Screen-edge spacing. On a phone the game runs under the status bar and
10
+ home indicator; keep HUD inside these. The muni app also injects
11
+ --mv-safe-* (Android web views don't report env() insets).
12
+ The TOP-LEFT corner (--exit-clear wide, below --safe-top) belongs to the
13
+ app's Exit button — keep it empty. */
14
+ :root {
15
+ --safe-top: max(env(safe-area-inset-top, 0px), var(--mv-safe-top, 0px));
16
+ --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--mv-safe-bottom, 0px));
17
+ --safe-left: max(env(safe-area-inset-left, 0px), var(--mv-safe-left, 0px));
18
+ --safe-right: max(env(safe-area-inset-right, 0px), var(--mv-safe-right, 0px));
19
+ --hud-top: calc(var(--safe-top) + 12px);
20
+ --exit-clear: 64px;
21
+ --ink: #f5f1e6;
22
+ --panel: rgba(18, 22, 28, 0.82);
23
+ --accent: {{GOLD}};
24
+ --danger: #e5484d;
25
+ }
26
+ * { margin: 0; padding: 0; box-sizing: border-box; }
27
+ html, body { width: 100%; height: 100%; overflow: hidden; }
28
+ body { background: {{DARK}}; color: var(--ink); font: 15px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; user-select: none; -webkit-user-select: none; }
29
+ {{CONTAINER_CSS}}
30
+ /* touch-action: none — drags steer and look, never scroll the page. */
31
+ canvas { display: block; touch-action: none; }
32
+ [hidden] { display: none !important; }
33
+
34
+ /* Lobby (src/lobby.js) */
35
+ #lobby { position: absolute; inset: 0; display: grid; place-items: center; padding: 16px; background: rgba(8, 10, 14, 0.45); z-index: 10; }
36
+ .card { background: var(--panel); border-radius: 16px; padding: 20px; width: min(340px, 100%); display: grid; gap: 10px; }
37
+ .card h1 { font-size: 26px; color: var(--accent); }
38
+ .card h2 { font-size: 22px; }
39
+ .card .who { opacity: 0.85; }
40
+ .card .hint { font-size: 12px; opacity: 0.65; }
41
+ .card .error { color: var(--danger); font-size: 13px; min-height: 1em; }
42
+ .card ol { padding-left: 22px; }
43
+ button { font: inherit; font-weight: 600; padding: 11px; border-radius: 10px; border: 0; background: #2c333d; color: var(--ink); cursor: pointer; }
44
+ button.primary { background: var(--accent); color: #1a1408; }
45
+ button:disabled { opacity: 0.5; }
46
+ form.join { display: flex; gap: 8px; }
47
+ form.join input { flex: 1; min-width: 0; font: inherit; letter-spacing: 2px; text-transform: uppercase; padding: 10px; border-radius: 10px; border: 1px solid #39414d; background: #151a21; color: var(--ink); }
48
+
49
+ /* HUD (src/arena-game.js) */
50
+ .hud { position: absolute; inset: 0; pointer-events: none; z-index: 5; }
51
+ .hud > * { position: absolute; }
52
+ .hud-status { top: var(--hud-top); left: 50%; transform: translateX(-50%); background: var(--panel); padding: 6px 14px; border-radius: 999px; font-weight: 600; white-space: nowrap; max-width: calc(100% - 2 * var(--exit-clear) - 24px); overflow: hidden; text-overflow: ellipsis; }
53
+ .hud-board { top: calc(var(--hud-top) + 48px); right: calc(var(--safe-right) + 10px); background: var(--panel); padding: 8px 10px; border-radius: 10px; font-size: 13px; min-width: 120px; }
54
+ .hud-board div { display: flex; justify-content: space-between; gap: 12px; }
55
+ .hud-board .me { color: var(--accent); }
56
+ .hud-leave { top: var(--hud-top); right: calc(var(--safe-right) + 10px); pointer-events: auto; padding: 6px 12px; font-size: 13px; }
57
+ .hud-crosshair { left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid rgba(255, 255, 255, 0.8); border-radius: 50%; }
58
+ .hud-crosshair.hit { border-color: var(--danger); transform: scale(1.3); }
59
+ .hud-banner { top: 34%; left: 50%; transform: translateX(-50%); font-size: 24px; font-weight: 700; text-shadow: 0 2px 8px #000; text-align: center; width: 90%; }
60
+ .hud-hp { bottom: calc(var(--safe-bottom) + 14px); left: 50%; transform: translateX(-50%); background: var(--panel); padding: 6px 14px; border-radius: 999px; font-weight: 600; }
61
+ .hud-ping { bottom: calc(var(--safe-bottom) + 18px); left: calc(var(--safe-left) + 12px); font-size: 11px; opacity: 0.6; }
62
+ .hud-results { inset: 0; display: grid; place-items: center; background: rgba(8, 10, 14, 0.6); pointer-events: auto; padding: 16px; }
63
+ .hud-fire { position: absolute; z-index: 6; right: calc(var(--safe-right) + 24px); bottom: calc(var(--safe-bottom) + 28px); width: 84px; height: 84px; border-radius: 50%; background: rgba(229, 72, 77, 0.55); display: grid; place-items: center; font-weight: 700; }
64
+ .hud-stick { position: fixed; z-index: 6; width: 110px; height: 110px; margin: -55px 0 0 -55px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.35); pointer-events: none; }
65
+ .hud-stick i { position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; margin: -22px 0 0 -22px; border-radius: 50%; background: rgba(255, 255, 255, 0.45); }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <div id="game-container">
70
+ <div id="lobby" hidden></div>
71
+ </div>
72
+ <script type="module" src="/src/main.js"></script>
73
+ </body>
74
+ </html>