@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 +75 -0
- package/CLAUDE.md +11 -0
- package/README.md +71 -52
- package/client/main.ts +1 -1
- package/llms-full.txt +346 -0
- package/llms.txt +1 -1
- package/package.json +37 -11
- package/src/game.ts +1 -1
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
|
-
#
|
|
1
|
+
# @netforge/template-arena
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
scatter coil and a rail lance. Every kill drops a heal cell.
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
12
|
-
| `src/manifest.ts` | Loads and compiles
|
|
13
|
-
| `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): three round upgrades
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
4
|
-
"description": "
|
|
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/
|
|
36
|
-
"@netforge/
|
|
37
|
-
"@netforge/engine": "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
|
-
//
|
|
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
|