@netforge/template-arena 0.0.0-stage → 0.2.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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The buy-in run in the store panel (earn.stake, the mandatory sink loop).
3
+ * The player picks an amount inside the game's terms and stakes it: the Kit
4
+ * spends it under the reason "stake" (earned coins first, then the wallet),
5
+ * then the room is told. The room fixes the stake from its own ledger when
6
+ * the next run starts (or this one, if it only just began) and says so.
7
+ */
8
+ import type { PotMessage, StakeMessage, StakeTermsView } from "@netforge/engine/net";
9
+ import type { KitStoreBridge } from "../src/store.js";
10
+
11
+ export interface StakeConn {
12
+ readonly welcome: { readonly stake?: StakeTermsView };
13
+ stake(amount?: number): void;
14
+ onStake(cb: (m: StakeMessage) => void): void;
15
+ }
16
+
17
+ const fmt = (n: number) => (Math.round(n * 100) / 100).toLocaleString("en-US");
18
+
19
+ /** Wires #stake-amount, #stake-go and #stake-line; hides #stake-row when the game takes no stakes. */
20
+ export function wireStake(conn: StakeConn, kit: KitStoreBridge, toast: (t: string) => void): void {
21
+ const row = document.getElementById("stake-row");
22
+ const input = document.getElementById("stake-amount") as HTMLInputElement | null;
23
+ const go = document.getElementById("stake-go") as HTMLButtonElement | null;
24
+ const line = document.getElementById("stake-line");
25
+ const terms = conn.welcome.stake;
26
+ if (!row || !input || !go || !line) return;
27
+ if (!terms) {
28
+ row.style.display = "none";
29
+ return;
30
+ }
31
+ const sym = kit.coins.symbol();
32
+ input.min = String(terms.min);
33
+ input.max = String(terms.max);
34
+ input.step = "any";
35
+ input.value = String(terms.min);
36
+ const idle = () => {
37
+ const a = Number(input.value) || terms.min;
38
+ line.textContent = `Up to ${fmt(a * terms.multiple)} ${sym} back`;
39
+ };
40
+ idle();
41
+ input.addEventListener("input", idle);
42
+ go.addEventListener("click", async () => {
43
+ const a = Number(input.value);
44
+ if (!(a >= terms.min && a <= terms.max)) return toast(`Stake ${fmt(terms.min)} to ${fmt(terms.max)} ${sym}`);
45
+ go.disabled = true;
46
+ try {
47
+ await kit.coins.spend(a, "stake");
48
+ conn.stake(a);
49
+ } catch {
50
+ toast(`Not enough ${sym}`);
51
+ } finally {
52
+ go.disabled = false;
53
+ }
54
+ });
55
+ conn.onStake((m) => {
56
+ if (m.state === "live") line.textContent = `Staked ${fmt(m.stake)}, up to ${fmt(m.ceiling ?? 0)} ${sym} this run`;
57
+ else if (m.state === "waiting") line.textContent = m.stake > 0 ? `Next run: ${fmt(m.stake)} ${sym}` : "Stake waits for tomorrow's cap";
58
+ else if (m.state === "closed") {
59
+ toast(`Stake ${fmt(m.stake)} ${sym} ${fmt(m.paid ?? 0)} back${m.lost && m.lost.length > 0 ? ` · gear lost` : ""}`);
60
+ idle();
61
+ } else toast(m.reason === "sign in to stake" ? "Sign in to stake" : "Stake not taken");
62
+ });
63
+ }
64
+
65
+ /** One losable gear SKU (store.sku grants { gear, loseBelow }): a buy button and its loss line. Hides #gear-row without one. */
66
+ export function wireGear(gear: { id: string; name: string; coins: number; loseBelow: number } | null, kit: KitStoreBridge, toast: (t: string) => void): void {
67
+ const row = document.getElementById("gear-row");
68
+ const name = document.getElementById("gear-name");
69
+ const line = document.getElementById("gear-line");
70
+ const buy = document.getElementById("gear-buy") as HTMLButtonElement | null;
71
+ if (!row || !name || !line || !buy) return;
72
+ if (!gear) {
73
+ row.style.display = "none";
74
+ return;
75
+ }
76
+ const sym = kit.coins.symbol();
77
+ name.textContent = gear.name;
78
+ line.textContent = `Staked runs only. Lost under ${Math.round(gear.loseBelow * 100)}% of a run's earn`;
79
+ const show = () => {
80
+ buy.textContent = kit.store.owns(gear.id) ? "Owned" : `${gear.coins} ${sym}`;
81
+ buy.disabled = kit.store.owns(gear.id);
82
+ };
83
+ show();
84
+ buy.addEventListener("click", async () => {
85
+ try {
86
+ await kit.store.buy(gear.id, { pay: "coins" });
87
+ } catch {
88
+ toast(`Not enough ${sym}`);
89
+ }
90
+ show();
91
+ });
92
+ }
93
+
94
+ /** The game's first gear SKU from its manifest, with its label. */
95
+ export function gearOf(skus: ReadonlyMap<string, { coins?: number; grants?: unknown }>, names: Readonly<Record<string, string>>): { id: string; name: string; coins: number; loseBelow: number } | null {
96
+ for (const [id, s] of skus) {
97
+ const g = s.grants as { gear?: string; loseBelow?: number } | undefined;
98
+ if (g?.gear && typeof g.loseBelow === "number" && s.coins !== undefined) return { id, name: names[id] ?? id, coins: s.coins, loseBelow: g.loseBelow };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export interface PotConn {
104
+ readonly welcome: { readonly pot?: { readonly entry: number; readonly recycle: number } };
105
+ pot(): void;
106
+ onPot(cb: (m: PotMessage) => void): void;
107
+ }
108
+
109
+ /** The round's PvP pot: #pot-go pays the entry and tells the room; #pot-line shows the pot. Hides #pot-row without one. */
110
+ export function wirePot(conn: PotConn, kit: KitStoreBridge, toast: (t: string) => void): void {
111
+ const row = document.getElementById("pot-row");
112
+ const go = document.getElementById("pot-go") as HTMLButtonElement | null;
113
+ const line = document.getElementById("pot-line");
114
+ const pot = conn.welcome.pot;
115
+ if (!row || !go || !line) return;
116
+ if (!pot) {
117
+ row.style.display = "none";
118
+ return;
119
+ }
120
+ const sym = kit.coins.symbol();
121
+ const idle = `${fmt(pot.entry)} ${sym} in. The round splits it`;
122
+ line.textContent = idle;
123
+ go.textContent = `${fmt(pot.entry)} ${sym}`;
124
+ go.addEventListener("click", async () => {
125
+ go.disabled = true;
126
+ try {
127
+ await kit.coins.spend(pot.entry, "pot");
128
+ conn.pot();
129
+ } catch {
130
+ toast(`Not enough ${sym}`);
131
+ } finally {
132
+ go.disabled = false;
133
+ }
134
+ });
135
+ conn.onPot((m) => {
136
+ if (m.state === "in") line.textContent = `In. Pot ${fmt(m.pot ?? m.entry)} ${sym}`;
137
+ else if (m.state === "waiting") line.textContent = "In the next round";
138
+ else if (m.state === "closed") {
139
+ toast(`Pot ${fmt(m.pot ?? 0)} ${sym} ${fmt(m.paid ?? 0)} to you`);
140
+ line.textContent = idle;
141
+ } else toast(m.reason === "sign in to enter" ? "Sign in to enter" : "Not entered");
142
+ });
143
+ }
package/llms-full.txt ADDED
@@ -0,0 +1,346 @@
1
+ # Net Forge arena shooter template (VOLTYARD)
2
+
3
+ > A twin-stick arena shooter for up to 16 players, with bots filling empty
4
+ > seats. Rounds last a few minutes; the top of the board takes the round.
5
+ > You change the game by editing src/game.ts only. The template already has
6
+ > loadouts, aiming, projectiles, lag-compensated beams, death and respawn,
7
+ > heal drops, rounds, a scoreboard, bots, netcode, rendering, touch and
8
+ > gamepad controls, earn and the store. Do not write renderers, netcode,
9
+ > physics or bot AI.
10
+
11
+ ## Files
12
+
13
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
14
+ game(), prefab(), weapon(), upgrade(), system(), earn.rule(), store.sku().
15
+ - src/names.json: the title and every label players read (weapons,
16
+ upgrades, earn rules, store items).
17
+ - Everything else is template machinery.
18
+
19
+ ## Rules the build enforces
20
+
21
+ - game({ template: "arena", style: "lowpoly" }) with players.loadout (1 to 4
22
+ declared weapons), an arena { halfSize, pillars, spawns } and a round.
23
+ - Arena weapons are kind "blaster", "beam" or "nova" (never bolt or orbit).
24
+ - Asset ids look like "style.lowpoly.mini_male_f", never URLs or paths, and must be in the asset library.
25
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it. The template draws everything in it, and a "style.lowpoly.<name>" id draws as that style's own model of the same name when the style has one.
26
+ - Ids are lower_snake_case. Every game declares at least one earn.rule and one earn.stake (the buy-in run).
27
+ - Pillars fit on the floor; spawns sit 1 m inside the floor and 1 m clear of pillars.
28
+ - 4 players plus seat bots must all score kills, and an earn rule must fire,
29
+ in a 4-minute playtest. A round should end within about 4 minutes.
30
+
31
+ ## Units
32
+
33
+ Seconds, metres, metres per second, hit points (players have 100).
34
+ Tick rate 60 Hz (shooters). Yaw 0 aims up the screen (-z).
35
+
36
+ ## The loop
37
+
38
+ Players spawn at a spawn point no opponent can see and at least 12 m from
39
+ all of them (a floor point when no declared spawn qualifies), with 2 s of
40
+ protection on joining, on respawn and at each new round (firing ends it). They move with one stick or WASD, aim with the
41
+ other or the mouse, hold fire, and pick weapons with 1 2 3 (or cycle).
42
+ A killed player drops players.drop (a heal cell), waits players.respawn
43
+ seconds and comes back. A round ends after round.seconds, or when someone
44
+ reaches round.frags kills; the end screen shows the board, then a new round.
45
+
46
+ ## Common changes
47
+
48
+ ### Tune a weapon
49
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44 });
50
+ blaster: count projectiles fanned over spread degrees at speed m/s for range m.
51
+ beam: an instant hit to range m (pillars stop it); the server rewinds what
52
+ the shooter saw (lag compensation), so keep cooldown at 0.8 s or more.
53
+ nova: hits every opponent within range m (a short-range blast).
54
+
55
+ ### Add a weapon to the loadout
56
+ weapon("ion_cannon", { kind: "blaster", damage: 40, cooldown: 1.2, range: 30, speed: 22, pierce: 2 });
57
+ players.loadout: ["arc_rifle", "scatter_coil", "rail_lance", "ion_cannon"] (at most 4; slot 4 is reached by cycling).
58
+ Add its label to names.json weapons.
59
+
60
+ ### Change the yard
61
+ arena: { halfSize: 26, pillars: [[x, z, radius], ...], spawns: [[x, z], ...] }.
62
+ Smaller halfSize and more pillars make closer fights. Keep spawns spread out.
63
+
64
+ ### Change round length or the kill target
65
+ round: { seconds: 180, frags: 25 }. 120 to 300 seconds plays well.
66
+
67
+ ### More or fewer bots
68
+ players.bots: 6 fills a room up to 6 players with bots (0 turns them off).
69
+ Bots never earn, and killing a bot pays no earn rule (it still scores and
70
+ shows in the kill feed). players.max caps humans in one room. Bots go easy on a
71
+ person with a poor record and sharpen as they win, ease off once they
72
+ lead the best person (they never stop shooting), and never take the round
73
+ (only a person reaches round.frags; the end screen names the best person).
74
+
75
+ ### Faster respawn, bigger heals
76
+ players.respawn: 2; prefab("volt_cell", { ..., kind: "pickup", value: 50 }).
77
+
78
+ ### Add an earn rule
79
+ earn.rule("first_blood", { when: "opponent.killed", n: 1, pay: 2, oncePer: "run" });
80
+ Events: opponent.killed (credits the killer), player.died (credits the one
81
+ who died), round.top (the top fragger when a round ends), round.finished
82
+ (everyone present at the end), run.won, and anything you emit(). A run is a
83
+ round. Each rule pays once per round per player, in coins; the room server
84
+ is the referee; bots never earn. Bot kills never pay: opponent.killed for a
85
+ bot's death, and anything a system emits while handling it (a streak that a
86
+ bot kill completes), score but count toward no earn rule. round.top pays
87
+ only when another person was in the round, never for topping a board of
88
+ bots. Alone with bots a player earns nothing from kills or the board.
89
+
90
+ ### The buy-in run (required)
91
+ earn.stake({ min: 10, max: 100, multiple: 2 });
92
+ Every game declares one, on one top-level line; a game without it does not
93
+ build. A player stakes coins at the start of a run (the Kit spends them as
94
+ coins.spend(amount, "stake"), then the client calls conn.stake()). The room
95
+ fixes the stake when the next run starts. A staked run pays its normal earn
96
+ plus a share of stake x multiple: the share is the part of the run's most
97
+ earn the player makes. What it does not win back stays in the reward pool.
98
+ multiple is 1 to 2. "stake" is reserved: never a SKU or meta upgrade id.
99
+
100
+ ### PvP pot (optional, arena only)
101
+ earn.pot({ entry: 20, recycle: 0.05 });
102
+ Each entrant pays the entry (coins.spend(entry, "pot"), then conn.pot()) and
103
+ joins the next round's pot. At the round's end the pot, less the recycle
104
+ share that stays in the reward pool, is split by the earn each entrant made.
105
+ A round where no entrant earned recycles the whole pot. "pot" is reserved.
106
+
107
+ ### Losable gear (optional)
108
+ store.sku("plating_kit", { coins: 120, grants: { gear: "plating", loseBelow: 0.25 } });
109
+ Gear is owned in the player's profile and sells for coins only. It brings one
110
+ level of its declared upgrade into every staked run, and is lost when that run
111
+ makes less than loseBelow of its most earn. Its coins stay in the reward pool.
112
+
113
+ ### Track something with a system
114
+ system("streaks", ({ on, emit }) => {
115
+ const streak = new Map<number, number>();
116
+ on("opponent.killed", (ids) => { for (const id of ids) { const n = (streak.get(id) ?? 0) + 1; streak.set(id, n); if (n === 5) emit("streak.five", [id]); } });
117
+ on("player.died", (ids) => { for (const id of ids) streak.set(id, 0); });
118
+ });
119
+ emit(event, ids) credits those players; emit(event) credits everyone present.
120
+ players() gives each player's id, position, health, alive, kills, deaths, bot.
121
+
122
+ ### Coins: earn and the store
123
+
124
+ Every game has coins: 1 coin = 1 of the game's own token, and players see
125
+ them under the token's own name, symbol and logo (never "coins" or "tokens"
126
+ as a label). Earn rules pay coins (pay is whole coins). The recommended
127
+ coin model (the default) sells in-round consumables and looks, never
128
+ permanent stat power:
129
+
130
+ store.sku("plating", { coins: 40 }); // one level of the "plating" upgrade for the round
131
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } }); // a finish, owned for good (saved in the player's profile)
132
+
133
+ A store item named after a declared upgrade(...) is a round upgrade: the
134
+ room grants one level of it when a player pays. Keep the five SKUs the
135
+ client is wired to (plating, servo, capacitor, skin_neon, skin_chrome) at
136
+ their prices, with the finishes' grants (neon 1, chrome 2). "Cosmetics only":
137
+ economy({ store: "cosmetics" }). Permanent upgrades are opt-in:
138
+ economy({ metaUpgrades: true }) and metaUpgrade(...), capped by the engine
139
+ at +15% a stat and +30% in all (see GAME-DESIGN.md). Price against what a round pays (the four rules here pay up
140
+ to 51): an upgrade under a round, a finish a few rounds. Before the game
141
+ launches coins are play money; the Kit switches by itself.
142
+
143
+ ### Change who the players are (the asset library)
144
+ players.prefab is a real CC0 character by id; each player is drawn in their
145
+ colour holding a blaster, and plays its walk, run and shoot animations.
146
+ players: { prefab: "style.lowpoly.soldier", ... } or a dressed original:
147
+ players: { prefab: "style.lowpoly.blocky_g", appearance: { tint: "#16e0ff", parts: ["antenna"] }, ... }
148
+ appearance: { tint: "#rrggbb", accent: "#rrggbb", scale: 0.4 to 3, parts: up to 4 }.
149
+ The heal cell is prefab("volt_cell", { model: "style.lowpoly.cell_heal", ... });
150
+ any pickup model works (style.lowpoly.gem_pink, style.lowpoly.coin, style.lowpoly.burger).
151
+
152
+ ## Systems API
153
+
154
+ every(seconds, n => ...), after(seconds, fn), onTick(fn), spawn(prefab, [x,y,z]),
155
+ spawnRing(prefab, { count, radius, around? }), alive(prefab?), players(),
156
+ emit(event, ids?), on(event, ids => ...), end("won" | "lost"), random(),
157
+ tick(), time(). Enemies are optional in the arena: a declared enemy prefab
158
+ spawned by a system chases the nearest player and can be shot.
159
+
160
+ ## Test
161
+
162
+ engine.test.run({ ticks: 60 * 240, bots: 4, assert: [...] }) plays the game
163
+ on the server sim with 4 players plus seat bots and reports kills, deaths,
164
+ rounds (runs.won), topScore, scorers, earned by rule, softlock and ms per tick.
165
+
166
+ ## Worked example: src/game.ts
167
+
168
+ This is the template's complete src/game.ts as it ships. A new game on this
169
+ template starts from this file and changes declarations in place.
170
+
171
+ ```ts
172
+ /**
173
+ * VOLTYARD: the arena shooter template.
174
+ *
175
+ * Wirejacks duel at night in a yard of humming transformers. Move with one
176
+ * hand, aim with the other, swap between a rifle, a scatter coil and a rail
177
+ * lance. Every kill drops a cell that heals. Rounds last three minutes or
178
+ * end at 25 kills; the top of the board takes the round. Bots fill empty
179
+ * seats so a round always has a fight.
180
+ *
181
+ * This file is the whole game as the model writes it: declarations only,
182
+ * against the typed API in @netforge/engine. Everything else (sim,
183
+ * netcode, lag compensation, renderer, bots, referee) is the template's
184
+ * machinery.
185
+ */
186
+ import { earn, game, prefab, store, system, upgrade, weapon } from "@netforge/engine";
187
+
188
+ export default game({
189
+ template: "arena",
190
+ style: "lowpoly",
191
+ tickRate: 60,
192
+ places: { yard: { chunks: "places/yard", spawn: [0, 1, 0] } },
193
+ players: {
194
+ prefab: "style.lowpoly.mini_male_f",
195
+ max: 16,
196
+ loadout: ["arc_rifle", "scatter_coil", "rail_lance"],
197
+ bots: 6,
198
+ respawn: 3,
199
+ drop: "volt_cell",
200
+ },
201
+ // The yard: 52 m square. Pillars are transformers, [x, z, radius]; spawns are [x, z].
202
+ arena: {
203
+ halfSize: 26,
204
+ pillars: [
205
+ [0, 0, 2.6],
206
+ [9, 9, 1.4],
207
+ [-9, 9, 1.4],
208
+ [9, -9, 1.4],
209
+ [-9, -9, 1.4],
210
+ [0, 16, 1.8],
211
+ [0, -16, 1.8],
212
+ [16, 0, 1.8],
213
+ [-16, 0, 1.8],
214
+ [18, 18, 1.2],
215
+ [-18, 18, 1.2],
216
+ [18, -18, 1.2],
217
+ [-18, -18, 1.2],
218
+ ],
219
+ spawns: [
220
+ [22, 22],
221
+ [-22, -22],
222
+ [22, -22],
223
+ [-22, 22],
224
+ [0, 22],
225
+ [0, -22],
226
+ [22, 0],
227
+ [-22, 0],
228
+ ],
229
+ },
230
+ round: { seconds: 180, frags: 25 },
231
+ });
232
+
233
+ // The heal cell a downed wirejack drops: walking over it restores 35 health.
234
+ prefab("volt_cell", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", value: 35 });
235
+
236
+ // The loadout. Damage in hit points (players have 100), cooldowns in seconds, ranges in metres.
237
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44, knockback: 0 });
238
+ weapon("scatter_coil", { kind: "blaster", damage: 9, cooldown: 0.75, range: 11, speed: 34, count: 7, spread: 26, knockback: 0 });
239
+ weapon("rail_lance", { kind: "beam", damage: 60, cooldown: 1.4, range: 40, knockback: 0 });
240
+
241
+ // Upgrades sold in the store, one level each, for the rest of the round.
242
+ upgrade("plating", { stat: "maxHealth", step: 25, maxLevel: 1 });
243
+ upgrade("servo", { stat: "speed", step: 0.08, maxLevel: 1 });
244
+ upgrade("capacitor", { stat: "haste", step: 0.12, maxLevel: 1 });
245
+
246
+ // Streaks: five kills without dying.
247
+ system("streaks", ({ on, emit }) => {
248
+ const streak = new Map<number, number>();
249
+ on("opponent.killed", (ids) => {
250
+ for (const id of ids) {
251
+ const n = (streak.get(id) ?? 0) + 1;
252
+ streak.set(id, n);
253
+ if (n === 5) emit("streak.five", [id]);
254
+ }
255
+ });
256
+ on("player.died", (ids) => {
257
+ for (const id of ids) streak.set(id, 0);
258
+ });
259
+ // A player's earning run started over (signed in, or staked): the streak does too.
260
+ on("run.started", (ids) => {
261
+ for (const id of ids) streak.set(id, 0);
262
+ });
263
+ });
264
+
265
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per round each. Bots never earn; bot kills and a board of bots pay nothing.
266
+ earn.rule("live_wire", { when: "opponent.killed", n: 3, pay: 4, oncePer: "run" });
267
+ earn.rule("long_shift", { when: "opponent.killed", n: 12, pay: 10, oncePer: "run" });
268
+ earn.rule("overload", { when: "streak.five", n: 1, pay: 12, oncePer: "run" });
269
+ earn.rule("top_of_the_yard", { when: "round.top", n: 1, pay: 25, oncePer: "run" });
270
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
271
+ // A staked run also pays up to stake x multiple, by the share of this run's earn the player makes.
272
+ // What it does not win back stays in the reward pool. Values are PLACEHOLDER.
273
+ earn.stake({ min: 10, max: 100, multiple: 2 });
274
+ // PvP pot: each entrant pays the entry before a round; the round splits the pot by the earn each entrant
275
+ // makes, and the recycle share stays in the reward pool. Values are PLACEHOLDER.
276
+ // Declared, not enabled: add `enabled: true` to run it (R-16 approved 2026-10-23).
277
+ earn.pot({ entry: 20, recycle: 0.05 });
278
+
279
+ // Store (the Kit), priced in coins (the default coin model): three round upgrades
280
+ // used in a round, and two cosmetic finishes owned for good (kept in the profile).
281
+ store.sku("plating", { coins: 40 });
282
+ store.sku("servo", { coins: 40 });
283
+ store.sku("capacitor", { coins: 60 });
284
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } });
285
+ store.sku("skin_chrome", { coins: 500, grants: { cosmetic: 2 } });
286
+ // Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
287
+ // and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
288
+ store.sku("plating_kit", { coins: 120, grants: { gear: "plating", loseBelow: 0.25 } });
289
+ ```
290
+
291
+ ## Validate and playtest
292
+
293
+ ### With the MCP server
294
+
295
+ Run `npx -y @netforge/mcp` (package @netforge/mcp, bin netforge-mcp) and
296
+ connect it to your coding agent. Its tools are netforge_list_templates, netforge_create_game,
297
+ netforge_explain_api, netforge_search_docs, netforge_validate_game, netforge_run_playtest and netforge_play_locally.
298
+
299
+ - netforge_validate_game checks src/game.ts against the engine's schemas and this
300
+ template's rules, and returns one actionable line per problem.
301
+ - netforge_run_playtest plays the game headless with bots and returns the report.
302
+ - netforge_play_locally gives the steps to open the game in a browser with kit play.
303
+
304
+ ### In code
305
+
306
+ Import the game module first so its declarations register, then run the
307
+ headless harness. This plays 4 players plus seat bots for 4 minutes at 60 Hz.
308
+
309
+ ```ts
310
+ import { engine } from "@netforge/engine";
311
+ import "./src/game.js";
312
+
313
+ const report = await engine.test.run({
314
+ ticks: 60 * 240,
315
+ bots: 4,
316
+ seed: 1,
317
+ assert: [
318
+ { name: "earn fired", check: (r) => r.earnEmitted > 0 },
319
+ { name: "no softlock", check: (r) => !r.softlock },
320
+ ],
321
+ });
322
+ if (report.failed.length > 0) throw new Error(report.failed.join("\n"));
323
+ ```
324
+
325
+ The report has kills, deaths, rounds (runs.won), topScore, scorers, earned by rule, softlock and ms per tick.
326
+
327
+ The template's own tests (vitest) are the reference checks:
328
+
329
+ - test/harness.test.ts
330
+ - test/host.test.ts
331
+ - test/newcomer.test.ts
332
+ - test/store.test.ts
333
+
334
+ ### Play it locally
335
+
336
+ In a folder with netnet.json and src/game.ts:
337
+
338
+ ```sh
339
+ npm install @netforge/kit-play @netforge/engine @netforge/template-arena
340
+ echo '{"template":"arena"}' > netnet.json
341
+ npx kit-play # opens http://127.0.0.1:4663
342
+ ```
343
+
344
+ kit play builds the template client with your src/game.ts and runs a local
345
+ room host with play money. No wallet, no chain and no hosted servers are
346
+ involved. Open the link in more tabs for more players.
package/llms.txt ADDED
@@ -0,0 +1,164 @@
1
+ # Net Forge arena shooter template (VOLTYARD)
2
+
3
+ > A twin-stick arena shooter for up to 16 players, with bots filling empty
4
+ > seats. Rounds last a few minutes; the top of the board takes the round.
5
+ > You change the game by editing src/game.ts only. The template already has
6
+ > loadouts, aiming, projectiles, lag-compensated beams, death and respawn,
7
+ > heal drops, rounds, a scoreboard, bots, netcode, rendering, touch and
8
+ > gamepad controls, earn and the store. Do not write renderers, netcode,
9
+ > physics or bot AI.
10
+
11
+ ## Files
12
+
13
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
14
+ game(), prefab(), weapon(), upgrade(), system(), earn.rule(), store.sku().
15
+ - src/names.json: the title and every label players read (weapons,
16
+ upgrades, earn rules, store items).
17
+ - Everything else is template machinery.
18
+
19
+ ## Rules the build enforces
20
+
21
+ - game({ template: "arena", style: "lowpoly" }) with players.loadout (1 to 4
22
+ declared weapons), an arena { halfSize, pillars, spawns } and a round.
23
+ - Arena weapons are kind "blaster", "beam" or "nova" (never bolt or orbit).
24
+ - Asset ids look like "style.lowpoly.mini_male_f", never URLs or paths, and must be in the asset library.
25
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it. The template draws everything in it, and a "style.lowpoly.<name>" id draws as that style's own model of the same name when the style has one.
26
+ - Ids are lower_snake_case. Every game declares at least one earn.rule and one earn.stake (the buy-in run).
27
+ - Pillars fit on the floor; spawns sit 1 m inside the floor and 1 m clear of pillars.
28
+ - 4 players plus seat bots must all score kills, and an earn rule must fire,
29
+ in a 4-minute playtest. A round should end within about 4 minutes.
30
+
31
+ ## Units
32
+
33
+ Seconds, metres, metres per second, hit points (players have 100).
34
+ Tick rate 60 Hz (shooters). Yaw 0 aims up the screen (-z).
35
+
36
+ ## The loop
37
+
38
+ Players spawn at a spawn point no opponent can see and at least 12 m from
39
+ all of them (a floor point when no declared spawn qualifies), with 2 s of
40
+ protection on joining, on respawn and at each new round (firing ends it). They move with one stick or WASD, aim with the
41
+ other or the mouse, hold fire, and pick weapons with 1 2 3 (or cycle).
42
+ A killed player drops players.drop (a heal cell), waits players.respawn
43
+ seconds and comes back. A round ends after round.seconds, or when someone
44
+ reaches round.frags kills; the end screen shows the board, then a new round.
45
+
46
+ ## Common changes
47
+
48
+ ### Tune a weapon
49
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44 });
50
+ blaster: count projectiles fanned over spread degrees at speed m/s for range m.
51
+ beam: an instant hit to range m (pillars stop it); the server rewinds what
52
+ the shooter saw (lag compensation), so keep cooldown at 0.8 s or more.
53
+ nova: hits every opponent within range m (a short-range blast).
54
+
55
+ ### Add a weapon to the loadout
56
+ weapon("ion_cannon", { kind: "blaster", damage: 40, cooldown: 1.2, range: 30, speed: 22, pierce: 2 });
57
+ players.loadout: ["arc_rifle", "scatter_coil", "rail_lance", "ion_cannon"] (at most 4; slot 4 is reached by cycling).
58
+ Add its label to names.json weapons.
59
+
60
+ ### Change the yard
61
+ arena: { halfSize: 26, pillars: [[x, z, radius], ...], spawns: [[x, z], ...] }.
62
+ Smaller halfSize and more pillars make closer fights. Keep spawns spread out.
63
+
64
+ ### Change round length or the kill target
65
+ round: { seconds: 180, frags: 25 }. 120 to 300 seconds plays well.
66
+
67
+ ### More or fewer bots
68
+ players.bots: 6 fills a room up to 6 players with bots (0 turns them off).
69
+ Bots never earn, and killing a bot pays no earn rule (it still scores and
70
+ shows in the kill feed). players.max caps humans in one room. Bots go easy on a
71
+ person with a poor record and sharpen as they win, ease off once they
72
+ lead the best person (they never stop shooting), and never take the round
73
+ (only a person reaches round.frags; the end screen names the best person).
74
+
75
+ ### Faster respawn, bigger heals
76
+ players.respawn: 2; prefab("volt_cell", { ..., kind: "pickup", value: 50 }).
77
+
78
+ ### Add an earn rule
79
+ earn.rule("first_blood", { when: "opponent.killed", n: 1, pay: 2, oncePer: "run" });
80
+ Events: opponent.killed (credits the killer), player.died (credits the one
81
+ who died), round.top (the top fragger when a round ends), round.finished
82
+ (everyone present at the end), run.won, and anything you emit(). A run is a
83
+ round. Each rule pays once per round per player, in coins; the room server
84
+ is the referee; bots never earn. Bot kills never pay: opponent.killed for a
85
+ bot's death, and anything a system emits while handling it (a streak that a
86
+ bot kill completes), score but count toward no earn rule. round.top pays
87
+ only when another person was in the round, never for topping a board of
88
+ bots. Alone with bots a player earns nothing from kills or the board.
89
+
90
+ ### The buy-in run (required)
91
+ earn.stake({ min: 10, max: 100, multiple: 2 });
92
+ Every game declares one, on one top-level line; a game without it does not
93
+ build. A player stakes coins at the start of a run (the Kit spends them as
94
+ coins.spend(amount, "stake"), then the client calls conn.stake()). The room
95
+ fixes the stake when the next run starts. A staked run pays its normal earn
96
+ plus a share of stake x multiple: the share is the part of the run's most
97
+ earn the player makes. What it does not win back stays in the reward pool.
98
+ multiple is 1 to 2. "stake" is reserved: never a SKU or meta upgrade id.
99
+
100
+ ### PvP pot (optional, arena only)
101
+ earn.pot({ entry: 20, recycle: 0.05 });
102
+ Each entrant pays the entry (coins.spend(entry, "pot"), then conn.pot()) and
103
+ joins the next round's pot. At the round's end the pot, less the recycle
104
+ share that stays in the reward pool, is split by the earn each entrant made.
105
+ A round where no entrant earned recycles the whole pot. "pot" is reserved.
106
+
107
+ ### Losable gear (optional)
108
+ store.sku("plating_kit", { coins: 120, grants: { gear: "plating", loseBelow: 0.25 } });
109
+ Gear is owned in the player's profile and sells for coins only. It brings one
110
+ level of its declared upgrade into every staked run, and is lost when that run
111
+ makes less than loseBelow of its most earn. Its coins stay in the reward pool.
112
+
113
+ ### Track something with a system
114
+ system("streaks", ({ on, emit }) => {
115
+ const streak = new Map<number, number>();
116
+ on("opponent.killed", (ids) => { for (const id of ids) { const n = (streak.get(id) ?? 0) + 1; streak.set(id, n); if (n === 5) emit("streak.five", [id]); } });
117
+ on("player.died", (ids) => { for (const id of ids) streak.set(id, 0); });
118
+ });
119
+ emit(event, ids) credits those players; emit(event) credits everyone present.
120
+ players() gives each player's id, position, health, alive, kills, deaths, bot.
121
+
122
+ ### Coins: earn and the store
123
+
124
+ Every game has coins: 1 coin = 1 of the game's own token, and players see
125
+ them under the token's own name, symbol and logo (never "coins" or "tokens"
126
+ as a label). Earn rules pay coins (pay is whole coins). The recommended
127
+ coin model (the default) sells in-round consumables and looks, never
128
+ permanent stat power:
129
+
130
+ store.sku("plating", { coins: 40 }); // one level of the "plating" upgrade for the round
131
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } }); // a finish, owned for good (saved in the player's profile)
132
+
133
+ A store item named after a declared upgrade(...) is a round upgrade: the
134
+ room grants one level of it when a player pays. Keep the five SKUs the
135
+ client is wired to (plating, servo, capacitor, skin_neon, skin_chrome) at
136
+ their prices, with the finishes' grants (neon 1, chrome 2). "Cosmetics only":
137
+ economy({ store: "cosmetics" }). Permanent upgrades are opt-in:
138
+ economy({ metaUpgrades: true }) and metaUpgrade(...), capped by the engine
139
+ at +15% a stat and +30% in all (see GAME-DESIGN.md). Price against what a round pays (the four rules here pay up
140
+ to 51): an upgrade under a round, a finish a few rounds. Before the game
141
+ launches coins are play money; the Kit switches by itself.
142
+
143
+ ### Change who the players are (the asset library)
144
+ players.prefab is a real CC0 character by id; each player is drawn in their
145
+ colour holding a blaster, and plays its walk, run and shoot animations.
146
+ players: { prefab: "style.lowpoly.soldier", ... } or a dressed original:
147
+ players: { prefab: "style.lowpoly.blocky_g", appearance: { tint: "#16e0ff", parts: ["antenna"] }, ... }
148
+ appearance: { tint: "#rrggbb", accent: "#rrggbb", scale: 0.4 to 3, parts: up to 4 }.
149
+ The heal cell is prefab("volt_cell", { model: "style.lowpoly.cell_heal", ... });
150
+ any pickup model works (style.lowpoly.gem_pink, style.lowpoly.coin, style.lowpoly.burger).
151
+
152
+ ## Systems API
153
+
154
+ every(seconds, n => ...), after(seconds, fn), onTick(fn), spawn(prefab, [x,y,z]),
155
+ spawnRing(prefab, { count, radius, around? }), alive(prefab?), players(),
156
+ emit(event, ids?), on(event, ids => ...), end("won" | "lost"), random(),
157
+ tick(), time(). Enemies are optional in the arena: a declared enemy prefab
158
+ spawned by a system chases the nearest player and can be shot.
159
+
160
+ ## Test
161
+
162
+ engine.test.run({ ticks: 60 * 240, bots: 4, assert: [...] }) plays the game
163
+ on the server sim with 4 players plus seat bots and reports kills, deaths,
164
+ rounds (runs.won), topScore, scorers, earned by rule, softlock and ms per tick.