@netforge/template-arena 0.1.0 → 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.
package/AGENTS.md ADDED
@@ -0,0 +1,75 @@
1
+ # Extending VOLTYARD
2
+
3
+ VOLTYARD is the arena shooter template of the Net Forge game engine. The template already has loadouts, aiming, projectiles, lag-compensated beams, death and respawn, heal drops, rounds, a scoreboard, seat bots, netcode, rendering, touch and gamepad controls, earn and the store. You change the game by changing declarations. Read `llms.txt` for every field, and `llms-full.txt` for the complete worked example.
4
+
5
+ ## The one file to edit
6
+
7
+ - `src/game.ts` is the whole game. Change declarations in place.
8
+ - `src/names.json`, next to it, holds the title and every label players read (weapons, upgrades, earn rules, store items). Add a label there for every new id.
9
+ - Everything else (`src/manifest.ts`, `src/store.ts`, `client/`, `test/`, `scripts/`) is template machinery. Do not edit it.
10
+
11
+ ## Imports
12
+
13
+ `src/game.ts` imports only from `@netforge/engine`. The shipped game uses:
14
+
15
+ ```ts
16
+ import { earn, game, prefab, store, system, upgrade, weapon } from "@netforge/engine";
17
+ ```
18
+
19
+ Also available for this genre: `economy` and `metaUpgrade` (opt-in permanent upgrades, capped by the engine), and `earn.pot` (an optional PvP pot, arena only).
20
+
21
+ ## Declarations
22
+
23
+ - `game({ template: "arena", style, places, players: { prefab, max, bots, loadout, respawn, drop }, arena: { halfSize, pillars, spawns }, round: { seconds, frags } })`.
24
+ - `weapon(id, { kind, damage, cooldown, range, ... })` with kind `"blaster"`, `"beam"` or `"nova"` only.
25
+ - `prefab(id, { model, kind: "pickup", value })` for the heal cell and other pickups. A declared enemy prefab spawned by a system chases the nearest player.
26
+ - `upgrade(id, { stat, step, maxLevel })` for round upgrades the store sells.
27
+ - `system(id, (ctx) => { ... })` with `every`, `after`, `onTick`, `spawn`, `spawnRing`, `alive`, `players`, `emit`, `on`, `end`, `random`, `tick`, `time`.
28
+ - `earn.rule(id, { when, n, pay, oncePer: "run" })`. Events: `opponent.killed`, `player.died`, `round.top`, `round.finished`, `run.won`, and anything a system emits.
29
+ - `earn.stake({ min, max, multiple })`, exactly one, on one top-level line.
30
+ - `store.sku(id, { coins, grants? })`.
31
+
32
+ ## Rules the build enforces
33
+
34
+ - Ids are lower_snake_case.
35
+ - Every game declares at least one `earn.rule` and exactly one `earn.stake`. A game without them does not build. `"stake"` and `"pot"` are reserved and are never a SKU or upgrade id.
36
+ - `players.loadout` names 1 to 4 declared weapons. Arena weapons are `blaster`, `beam` or `nova`, never `bolt` or `orbit`. Keep beam cooldowns at 0.8 s or more.
37
+ - Pillars fit on the floor. Spawns sit 1 m inside the floor and 1 m clear of pillars.
38
+ - Asset ids are library ids such as `"style.lowpoly.mini_male_f"`, never URLs or paths, and must exist in the asset library.
39
+ - Keep `game({ style })` as the creator picked it: lowpoly, toon, ps1, pixel, voxel or handdrawn.
40
+ - Bots never earn, and killing a bot pays no earn rule.
41
+ - In a 4-minute playtest, 4 players plus seat bots must all score kills and an earn rule must fire. A round should end within about 4 minutes.
42
+ - Keep the five SKUs the client is wired to at their prices: `plating`, `servo`, `capacitor`, `skin_neon` (cosmetic 1) and `skin_chrome` (cosmetic 2).
43
+
44
+ ## Validate
45
+
46
+ - With the MCP server (`npx -y @netforge/mcp`), call `netforge_validate_game` on `src/game.ts`. It returns one actionable line per problem.
47
+ - In this package, run the tests with vitest: `test/harness.test.ts`, `test/host.test.ts`, `test/newcomer.test.ts` and `test/store.test.ts`.
48
+ - A bad declaration throws `GameDefinitionError` with a line that says what to change.
49
+
50
+ ## Playtest
51
+
52
+ - With the MCP server, call `netforge_run_playtest`.
53
+ - In code, import the game module and run the headless harness:
54
+
55
+ ```ts
56
+ import { engine } from "@netforge/engine";
57
+ import "./src/game.js";
58
+
59
+ const report = await engine.test.run({ ticks: 60 * 240, bots: 4, seed: 1 });
60
+ console.log(report.kills, report.deaths, report.earned, report.softlock);
61
+ ```
62
+
63
+ The report has kills, deaths, rounds (`runs.won`), `topScore`, `scorers`, earn by rule, softlock and ms per tick.
64
+
65
+ ## Play locally
66
+
67
+ In a folder with `netnet.json` (`{"template":"arena"}`) and `src/game.ts`, with `@netforge/kit-play` and `@netforge/engine` installed, run `npx kit-play`. It opens http://127.0.0.1:4663 with play money. The MCP tool `netforge_play_locally` gives the same steps.
68
+
69
+ ## Do not
70
+
71
+ - Do not write renderers, netcode, physics or bot AI. The template has them.
72
+ - Do not reference assets by URL or file path.
73
+ - Do not import anything other than `@netforge/engine` in `src/game.ts`.
74
+ - Do not edit `client/`, `src/manifest.ts` or `src/store.ts`.
75
+ - Do not rename or reprice the SKUs the client is wired to.
package/CLAUDE.md ADDED
@@ -0,0 +1,11 @@
1
+ # CLAUDE.md
2
+
3
+ This package is VOLTYARD, a Net Forge game template. Read AGENTS.md before changing this game. It lists the declarations this genre has, the rules the build enforces, and how to validate, playtest and play the game locally.
4
+
5
+ The five hard rules:
6
+
7
+ 1. Edit only src/game.ts (and src/names.json for the labels players read).
8
+ 2. Import only from @netforge/engine.
9
+ 3. Ids are lower_snake_case.
10
+ 4. Declare at least one earn.rule and exactly one earn.stake.
11
+ 5. Never write renderers, netcode, physics or bot AI, and never reference assets by URL or file path (use library ids such as "style.lowpoly.frog").
package/README.md CHANGED
@@ -1,64 +1,83 @@
1
- # VOLTYARD (Template 2: arena shooter)
1
+ # @netforge/template-arena
2
2
 
3
- Wirejacks duel at night in a yard of humming transformers. Twin-stick: move
4
- with one hand, aim with the other, hold fire. Swap between an arc rifle, a
5
- scatter coil and a rail lance. Every kill drops a heal cell. Rounds last
6
- three minutes or end at 25 kills; the top of the board takes the round.
7
- Bots fill empty seats, so one player alone still gets a fight.
3
+ An arena shooter game template for Net Forge: a twin-stick shooter for up to 16 players, with bots that fill empty seats (VOLTYARD).
4
+
5
+ Wirejacks duel at night in a yard of humming transformers. Players move with one hand, aim with the other and hold fire, swapping between an arc rifle, a scatter coil and a rail lance. Every kill drops a heal cell. A round lasts three minutes or ends at 25 kills, and the top of the board takes the round. Bots fill empty seats, so one player alone still gets a fight.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @netforge/template-arena
11
+ ```
12
+
13
+ You rarely import this package directly. kit play (`@netforge/kit-play`) and the Net Forge MCP server use it to build and run a game whose `src/game.ts` you write.
14
+
15
+ ## Play it locally
16
+
17
+ ```sh
18
+ mkdir -p my-game/src && cd my-game
19
+ npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-arena
20
+ cp node_modules/@netforge/template-arena/src/game.ts src/
21
+ echo '{"template":"arena"}' > netnet.json
22
+ npx kit-play # opens http://127.0.0.1:4663
23
+ ```
24
+
25
+ Open the Invite link (`?room=<id>`) in another tab to play together, or start with `--host 0.0.0.0` and open it on a phone on the same network. Coins are play money. Real-token earning, stakes and claims run only on the hosted Net Forge server.
26
+
27
+ ## Change the game
28
+
29
+ Edit `src/game.ts` and run `npx kit-play` again. For example, make the arc rifle hit harder and fire a little slower:
30
+
31
+ ```ts
32
+ weapon("arc_rifle", { kind: "blaster", damage: 16, cooldown: 0.2, range: 26, speed: 44, knockback: 0 });
33
+ ```
34
+
35
+ Or shorten the round and lower the kill target in `game({ ... })`:
36
+
37
+ ```ts
38
+ round: { seconds: 120, frags: 15 },
39
+ ```
40
+
41
+ ## How it plays on the server
42
+
43
+ - Server-authoritative at 60 Hz. Your own wirejack is predicted (move, aim, weapon, cooldowns), and everyone else is drawn about 100 ms in the past.
44
+ - The rail lance is a hitscan resolved with lag compensation: the server rewinds the other players to what you saw, capped at 200 ms. Rifle and scatter shots are server projectiles.
45
+ - Seat bots (`players.bots`) join while at least one person is in the room and leave as people join. They adapt to each person's record in the round, ease off when they lead, never earn and never reach the frag limit. Killing a bot scores and shows in the feed but pays no earn rule.
46
+ - Spawns are out of every opponent's sight and 12 m clear, with 2 s of protection on joining, respawning and each new round.
47
+ - Earn: kills of people (3 and 12 in a round), a five-kill streak, and topping the board with another person in the round. Bot kills and a board of bots pay nothing. The room server is the referee.
48
+
49
+ ## Files
8
50
 
9
51
  | Path | What |
10
52
  |---|---|
11
- | `src/game.ts` | The game, as the model writes it (declarations only). |
12
- | `src/manifest.ts` | Loads and compiles it; `arenaAdapter()` for the room server and client. Room template `shooter`. |
13
- | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): three round upgrades (40, 40, 60 coins) and two finishes (300, 500 coins), shown under the token's own symbol and logo; mock until the token is live. |
53
+ | `src/game.ts` | The game, as declarations only. This is the file you edit. |
54
+ | `src/manifest.ts` | Loads and compiles the game; `arenaAdapter()` for the room host and the client. |
55
+ | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): three round upgrades and two finishes. |
14
56
  | `client/` | The browser client: three.js scene, HUD, twin-stick input (keys and mouse, gamepad, touch), game feel. |
15
- | `test/` | Playtest bots through `engine.test.run`, and the store. |
16
- | `llms.txt` | How to change the game (for agents). |
57
+ | `test/` | Playtest bots through `engine.test.run`, the newcomer test and the store test. |
58
+ | `llms.txt`, `llms-full.txt` | How to change the game, for agents. |
17
59
 
18
- ## How it plays on the server
60
+ ## Controls
61
+
62
+ WASD to move, mouse to aim, click to fire, 1 2 3 or Q to change weapon, Space to dash, Tab for the board. On touch, the left stick moves, the right stick aims and fires, and SWAP and DASH are buttons. The backtick key shows stats.
19
63
 
20
- - Server-authoritative at 60 Hz. Your own wirejack is predicted (move, aim,
21
- weapon, cooldowns); everyone else is drawn ~100 ms in the past.
22
- - The rail lance is a hitscan resolved with lag compensation: the server
23
- rewinds the other players to what you saw (capped at 200 ms). Rifle and
24
- scatter shots are server projectiles.
25
- - Seat bots (`players.bots`) join while at least one person is in the
26
- room and leave as people join. Against a person they react late, aim
27
- loose and fire in bursts by that person's record this round, sharpening
28
- as the person wins. A bot level with or ahead of the best person eases
29
- off (looser aim, slower reactions, and it would rather fight other bots),
30
- but bots never stop shooting and never earn. Killing a bot scores and
31
- shows in the feed but pays no earn rule. Only a person reaches the frag
32
- limit.
33
- - Spawns: out of every opponent's sight (physics raycasts) and 12 m clear,
34
- with 2 s of protection on joining, respawning and each new round.
35
- - `test/newcomer.test.ts` plays a deliberately weak new player alone with
36
- the seat bots over 10 rounds: first kill within about 20 s, no deaths
37
- within 3 s of spawning, top half in most rounds without winning every
38
- one, no bot reaching the frag limit. It also reports what the newcomer
39
- earns per round, which alone with bots is nothing.
40
- - Earn: kills of people (3 and 12 in a round), a five-kill streak, and
41
- topping the board with another person in the round. Bot kills and a board
42
- of bots pay nothing, enforced by the engine's
43
- `GameRunner` for every arena game. The room server is the referee; before graduation earn is credited
44
- as points, after it the game's own coins under its own name.
45
-
46
- ## Run it
47
-
48
- This template runs on your own computer with play money through
49
- `kit play` (`@netforge/kit-play`). From the repository root:
64
+ ## For AI agents
65
+
66
+ Read `AGENTS.md` and `llms.txt` before editing. The Net Forge MCP server gives a coding agent the templates, the API and a playtest loop:
50
67
 
51
68
  ```sh
52
- pnpm install --frozen-lockfile && pnpm build
53
- mkdir -p play-arena/src && cp packages/template-arena/src/game.ts play-arena/src/
54
- echo '{"template":"arena"}' > play-arena/netnet.json
55
- node packages/kit-play/dist/cli.js play-arena # opens http://127.0.0.1:4663
69
+ npx -y @netforge/mcp
56
70
  ```
57
71
 
58
- Edit `play-arena/src/game.ts` and run it again to try a change. Real-token
59
- earning, stakes and claims run only on the hosted Net Forge server.
72
+ The package is `@netforge/mcp` and its bin is `netforge-mcp`. Its tools are `netforge_list_templates`, `netforge_create_game`, `netforge_explain_api`, `netforge_search_docs`, `netforge_validate_game`, `netforge_run_playtest` and `netforge_play_locally`.
73
+
74
+ To set up a coding agent in a game project, run `npx netforge setup agent`. It writes AGENTS.md, CLAUDE.md, Cursor rules and `.mcp.json` into the project. In Claude Code you can also add the server directly with `claude mcp add netforge -- npx -y @netforge/mcp`.
75
+
76
+ ## Docs
77
+
78
+ - https://forge.netnet.capital/docs/templates/arena
79
+ - `llms.txt` and `llms-full.txt` in this package. The full file includes the complete `src/game.ts` and the validate and playtest steps.
80
+
81
+ ## License
60
82
 
61
- Open the Invite link in another tab or on a phone on the same network to
62
- play together (`?room=<id>`). Keys: WASD, mouse aim, click fire, 1 2 3 or Q
63
- weapon, Space dash, Tab board. Touch: left stick moves, right stick aims and
64
- fires, SWAP and DASH buttons. ` shows stats.
83
+ MIT. See LICENSE and NOTICE.
package/client/main.ts CHANGED
@@ -146,7 +146,7 @@ async function main(): Promise<void> {
146
146
  if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("Room full, new room"), 500);
147
147
  if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("Update ready. Reload."), 800);
148
148
  const linkFor = (base: string) =>
149
- `${base}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
149
+ `${base}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}`;
150
150
  history.replaceState(null, "", `${linkFor(location.pathname)}${autopilot ? `&autopilot=${autopilotMode}` : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
151
151
 
152
152
  // The asset library: real models by library id (./library next to the page, or the published CID). ?shapes=1 draws the fallback shapes only.
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 CHANGED
@@ -1,4 +1,4 @@
1
- # VOLTYARD: the arena shooter template (NetNet game runtime, Template 2)
1
+ # Net Forge arena shooter template (VOLTYARD)
2
2
 
3
3
  > A twin-stick arena shooter for up to 16 players, with bots filling empty
4
4
  > seats. Rounds last a few minutes; the top of the board takes the round.
package/package.json CHANGED
@@ -1,12 +1,35 @@
1
1
  {
2
2
  "name": "@netforge/template-arena",
3
- "version": "0.1.0",
4
- "description": "VOLTYARD: Template 2 of the NetNet game runtime, an arena shooter with lag-compensated beams, loadouts, rounds and seat-filling bots. The game module, its browser client and its playtest bots.",
3
+ "version": "0.2.0",
4
+ "description": "Arena shooter game template for Net Forge: up to 16 players, lag-compensated beams, loadouts, rounds and seat-filling bots (VOLTYARD).",
5
+ "keywords": [
6
+ "game-template",
7
+ "multiplayer",
8
+ "browser-game",
9
+ "html5-game",
10
+ "threejs",
11
+ "game-engine",
12
+ "typescript",
13
+ "gamedev",
14
+ "ai-agents",
15
+ "mcp",
16
+ "net-forge",
17
+ "io-game",
18
+ "netcode"
19
+ ],
5
20
  "license": "MIT",
6
21
  "type": "module",
22
+ "sideEffects": [
23
+ "./src/game.ts",
24
+ "./src/manifest.ts",
25
+ "./client/**"
26
+ ],
7
27
  "exports": {
8
28
  ".": "./src/manifest.ts",
9
- "./store": "./src/store.ts"
29
+ "./store": "./src/store.ts",
30
+ "./package.json": "./package.json",
31
+ "./llms.txt": "./llms.txt",
32
+ "./llms-full.txt": "./llms-full.txt"
10
33
  },
11
34
  "files": [
12
35
  "src",
@@ -15,26 +38,32 @@
15
38
  "llms.txt",
16
39
  "README.md",
17
40
  "NOTICE",
18
- "LICENSE"
41
+ "LICENSE",
42
+ "llms-full.txt",
43
+ "AGENTS.md",
44
+ "CLAUDE.md"
19
45
  ],
20
46
  "publishConfig": {
21
47
  "access": "public"
22
48
  },
49
+ "homepage": "https://forge.netnet.capital/docs/templates/arena",
50
+ "bugs": {
51
+ "url": "https://github.com/netnetdev/net-forge/issues"
52
+ },
23
53
  "repository": {
24
54
  "type": "git",
25
55
  "url": "git+https://github.com/netnetdev/net-forge.git",
26
56
  "directory": "packages/template-arena"
27
57
  },
28
- "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-arena#readme",
29
58
  "engines": {
30
59
  "node": ">=20"
31
60
  },
32
61
  "dependencies": {
33
62
  "nipplejs": "1.0.4",
34
63
  "three": "0.186.1",
35
- "@netforge/engine-sim": "0.1.0",
36
- "@netforge/asset-library": "0.1.0",
37
- "@netforge/engine": "0.1.0"
64
+ "@netforge/asset-library": "0.2.0",
65
+ "@netforge/engine": "0.2.0",
66
+ "@netforge/engine-sim": "0.2.0"
38
67
  },
39
68
  "devDependencies": {
40
69
  "@types/node": "22.20.1",
@@ -44,9 +73,6 @@
44
73
  "vite": "6.4.3",
45
74
  "vitest": "3.2.7"
46
75
  },
47
- "bugs": {
48
- "url": "https://github.com/netnetdev/net-forge/issues"
49
- },
50
76
  "scripts": {
51
77
  "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
52
78
  "typecheck": "tsc -p tsconfig.json",
package/src/game.ts CHANGED
@@ -102,7 +102,7 @@ earn.rule("top_of_the_yard", { when: "round.top", n: 1, pay: 25, oncePer: "run"
102
102
  earn.stake({ min: 10, max: 100, multiple: 2 });
103
103
  // PvP pot: each entrant pays the entry before a round; the round splits the pot by the earn each entrant
104
104
  // makes, and the recycle share stays in the reward pool. Values are PLACEHOLDER.
105
- // Off until a legal review: declared, not enabled, so no room takes an entry.
105
+ // Declared, not enabled: add `enabled: true` to run it (R-16 approved 2026-10-23).
106
106
  earn.pot({ entry: 20, recycle: 0.05 });
107
107
 
108
108
  // Store (the Kit), priced in coins (the default coin model): three round upgrades