@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.
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "@netforge/template-arena",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ ],
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "sideEffects": [
23
+ "./src/game.ts",
24
+ "./src/manifest.ts",
25
+ "./client/**"
26
+ ],
27
+ "exports": {
28
+ ".": "./src/manifest.ts",
29
+ "./store": "./src/store.ts",
30
+ "./package.json": "./package.json",
31
+ "./llms.txt": "./llms.txt",
32
+ "./llms-full.txt": "./llms-full.txt"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "client",
37
+ "!client/vite.config.ts",
38
+ "llms.txt",
39
+ "README.md",
40
+ "NOTICE",
41
+ "LICENSE",
42
+ "llms-full.txt",
43
+ "AGENTS.md",
44
+ "CLAUDE.md"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "homepage": "https://forge.netnet.capital/docs/templates/arena",
50
+ "bugs": {
51
+ "url": "https://github.com/netnetdev/net-forge/issues"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/netnetdev/net-forge.git",
56
+ "directory": "packages/template-arena"
57
+ },
58
+ "engines": {
59
+ "node": ">=20"
60
+ },
61
+ "dependencies": {
62
+ "nipplejs": "1.0.4",
63
+ "three": "0.186.1",
64
+ "@netforge/asset-library": "0.2.0",
65
+ "@netforge/engine": "0.2.0",
66
+ "@netforge/engine-sim": "0.2.0"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "22.20.1",
70
+ "@types/three": "0.186.0",
71
+ "tsx": "4.23.0",
72
+ "typescript": "5.8.3",
73
+ "vite": "6.4.3",
74
+ "vitest": "3.2.7"
75
+ },
76
+ "scripts": {
77
+ "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
78
+ "typecheck": "tsc -p tsconfig.json",
79
+ "test": "vitest run",
80
+ "dev": "vite --config client/vite.config.ts",
81
+ "preview": "vite preview --config client/vite.config.ts"
82
+ }
6
83
  }
package/src/game.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * VOLTYARD: the arena shooter template.
3
+ *
4
+ * Wirejacks duel at night in a yard of humming transformers. Move with one
5
+ * hand, aim with the other, swap between a rifle, a scatter coil and a rail
6
+ * lance. Every kill drops a cell that heals. Rounds last three minutes or
7
+ * end at 25 kills; the top of the board takes the round. Bots fill empty
8
+ * seats so a round always has a fight.
9
+ *
10
+ * This file is the whole game as the model writes it: declarations only,
11
+ * against the typed API in @netforge/engine. Everything else (sim,
12
+ * netcode, lag compensation, renderer, bots, referee) is the template's
13
+ * machinery.
14
+ */
15
+ import { earn, game, prefab, store, system, upgrade, weapon } from "@netforge/engine";
16
+
17
+ export default game({
18
+ template: "arena",
19
+ style: "lowpoly",
20
+ tickRate: 60,
21
+ places: { yard: { chunks: "places/yard", spawn: [0, 1, 0] } },
22
+ players: {
23
+ prefab: "style.lowpoly.mini_male_f",
24
+ max: 16,
25
+ loadout: ["arc_rifle", "scatter_coil", "rail_lance"],
26
+ bots: 6,
27
+ respawn: 3,
28
+ drop: "volt_cell",
29
+ },
30
+ // The yard: 52 m square. Pillars are transformers, [x, z, radius]; spawns are [x, z].
31
+ arena: {
32
+ halfSize: 26,
33
+ pillars: [
34
+ [0, 0, 2.6],
35
+ [9, 9, 1.4],
36
+ [-9, 9, 1.4],
37
+ [9, -9, 1.4],
38
+ [-9, -9, 1.4],
39
+ [0, 16, 1.8],
40
+ [0, -16, 1.8],
41
+ [16, 0, 1.8],
42
+ [-16, 0, 1.8],
43
+ [18, 18, 1.2],
44
+ [-18, 18, 1.2],
45
+ [18, -18, 1.2],
46
+ [-18, -18, 1.2],
47
+ ],
48
+ spawns: [
49
+ [22, 22],
50
+ [-22, -22],
51
+ [22, -22],
52
+ [-22, 22],
53
+ [0, 22],
54
+ [0, -22],
55
+ [22, 0],
56
+ [-22, 0],
57
+ ],
58
+ },
59
+ round: { seconds: 180, frags: 25 },
60
+ });
61
+
62
+ // The heal cell a downed wirejack drops: walking over it restores 35 health.
63
+ prefab("volt_cell", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", value: 35 });
64
+
65
+ // The loadout. Damage in hit points (players have 100), cooldowns in seconds, ranges in metres.
66
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44, knockback: 0 });
67
+ weapon("scatter_coil", { kind: "blaster", damage: 9, cooldown: 0.75, range: 11, speed: 34, count: 7, spread: 26, knockback: 0 });
68
+ weapon("rail_lance", { kind: "beam", damage: 60, cooldown: 1.4, range: 40, knockback: 0 });
69
+
70
+ // Upgrades sold in the store, one level each, for the rest of the round.
71
+ upgrade("plating", { stat: "maxHealth", step: 25, maxLevel: 1 });
72
+ upgrade("servo", { stat: "speed", step: 0.08, maxLevel: 1 });
73
+ upgrade("capacitor", { stat: "haste", step: 0.12, maxLevel: 1 });
74
+
75
+ // Streaks: five kills without dying.
76
+ system("streaks", ({ on, emit }) => {
77
+ const streak = new Map<number, number>();
78
+ on("opponent.killed", (ids) => {
79
+ for (const id of ids) {
80
+ const n = (streak.get(id) ?? 0) + 1;
81
+ streak.set(id, n);
82
+ if (n === 5) emit("streak.five", [id]);
83
+ }
84
+ });
85
+ on("player.died", (ids) => {
86
+ for (const id of ids) streak.set(id, 0);
87
+ });
88
+ // A player's earning run started over (signed in, or staked): the streak does too.
89
+ on("run.started", (ids) => {
90
+ for (const id of ids) streak.set(id, 0);
91
+ });
92
+ });
93
+
94
+ // 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.
95
+ earn.rule("live_wire", { when: "opponent.killed", n: 3, pay: 4, oncePer: "run" });
96
+ earn.rule("long_shift", { when: "opponent.killed", n: 12, pay: 10, oncePer: "run" });
97
+ earn.rule("overload", { when: "streak.five", n: 1, pay: 12, oncePer: "run" });
98
+ earn.rule("top_of_the_yard", { when: "round.top", n: 1, pay: 25, oncePer: "run" });
99
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
100
+ // A staked run also pays up to stake x multiple, by the share of this run's earn the player makes.
101
+ // What it does not win back stays in the reward pool. Values are PLACEHOLDER.
102
+ earn.stake({ min: 10, max: 100, multiple: 2 });
103
+ // PvP pot: each entrant pays the entry before a round; the round splits the pot by the earn each entrant
104
+ // makes, and the recycle share stays in the reward pool. Values are PLACEHOLDER.
105
+ // Declared, not enabled: add `enabled: true` to run it (R-16 approved 2026-10-23).
106
+ earn.pot({ entry: 20, recycle: 0.05 });
107
+
108
+ // Store (the Kit), priced in coins (the default coin model): three round upgrades
109
+ // used in a round, and two cosmetic finishes owned for good (kept in the profile).
110
+ store.sku("plating", { coins: 40 });
111
+ store.sku("servo", { coins: 40 });
112
+ store.sku("capacitor", { coins: 60 });
113
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } });
114
+ store.sku("skin_chrome", { coins: 500, grants: { cosmetic: 2 } });
115
+ // Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
116
+ // and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
117
+ store.sku("plating_kit", { coins: 120, grants: { gear: "plating", loseBelow: 0.25 } });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Loads the game module once per process and compiles it. The room server,
3
+ * the client, the bots and the tests all start here.
4
+ */
5
+ import { collectManifest, compileGame, engineSimAdapter } from "@netforge/engine/game";
6
+ import type { CompiledGame, EngineRoomWorld, RunnerOptions } from "@netforge/engine/game";
7
+ import type { SimAdapter } from "@netforge/engine/net";
8
+ import type { InputCommand } from "@netforge/engine-sim";
9
+ import "./game.js";
10
+
11
+ /** Content version: bump with any change to game.ts (hot swap compares it). */
12
+ export const GAME_VERSION = "voltyard-0.1.1";
13
+ export const GAME_NAME = "voltyard";
14
+ /** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
15
+ export const TEMPLATE = "shooter";
16
+ export const PLACE = "yard";
17
+
18
+ /**
19
+ * Collected as soon as the module is imported: one process may load more
20
+ * than one game module (the room server hosts every template), and each
21
+ * manifest takes its own game before the next module declares.
22
+ */
23
+ const MANIFEST = collectManifest();
24
+ let compiled: CompiledGame | null = null;
25
+
26
+ /** The compiled game (weapons, loadout, arena, rounds as sim content). */
27
+ export function arenaGame(): CompiledGame {
28
+ compiled ??= compileGame(MANIFEST);
29
+ return compiled;
30
+ }
31
+
32
+ /** The SimAdapter for this game. `runner` options are server-side measurement modes. */
33
+ export function arenaAdapter(runner?: RunnerOptions): SimAdapter<EngineRoomWorld, InputCommand> {
34
+ return engineSimAdapter({ name: GAME_NAME, version: GAME_VERSION, game: arenaGame(), ...(runner ? { runner } : {}) });
35
+ }
36
+
37
+ /** Cosmetic ids for store SKUs (0 is the default finish). */
38
+ export const COSMETICS: Readonly<Record<string, number>> = { skin_neon: 1, skin_chrome: 2 };
package/src/store.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Store hooks (the Coins model): every
3
+ * game has coins, 1 coin = 1 game token, shown under the token's own name,
4
+ * symbol and logo. VOLTYARD sells three round upgrades (a store command
5
+ * named after the upgrade grants one level for the rest of the round) and
6
+ * two cosmetic finishes (permanent), all priced in coins.
7
+ *
8
+ * `KitStoreBridge` is the slice of @netforge/kit's `Kit` this game
9
+ * calls, with the Kit's own names (`kit.coins.*`, `kit.store.owns/buy`), so
10
+ * the real Kit drops in unchanged. Until a game's token is live `mockKit()`
11
+ * stands in, in memory. Earned coins are spent first (off chain, no wallet
12
+ * prompt), then the wallet, exactly as the Kit does.
13
+ */
14
+ export interface KitStoreBridge {
15
+ readonly store: {
16
+ owns(id: string): boolean;
17
+ count(id: string): number;
18
+ buy(id: string, opts?: { pay?: "usdg" | "token" | "coins" }): Promise<unknown>;
19
+ };
20
+ readonly coins: {
21
+ /** The token's own name, symbol and logo (its metadata). Never a generic "COIN". */
22
+ name(): string;
23
+ symbol(): string;
24
+ logo(): string | null;
25
+ /** Whole coins: earned (unclaimed) plus the wallet. */
26
+ balance(): number;
27
+ /** Earned first, then the wallet. `key` proves the paid room command (sent as the command's `proof`). */
28
+ spend(amount: number, reason: string): Promise<{ key: string | null }>;
29
+ };
30
+ }
31
+
32
+ /** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
33
+ export const COIN_PRICES = { plating: 40, servo: 40, capacitor: 60, skin_neon: 300, skin_chrome: 500 } as const;
34
+ export type SkuId = keyof typeof COIN_PRICES;
35
+ /** Round upgrades: spent, applied by the room for the rest of the round. */
36
+ export const UPGRADE_SKUS = ["plating", "servo", "capacitor"] as const;
37
+ /** Cosmetic finishes: bought once, owned for good. */
38
+ export const COSMETIC_SKUS = ["skin_neon", "skin_chrome"] as const;
39
+
40
+ /** The template's own coin, as its token metadata names it until a creator renames it. */
41
+ export const VOLT_COIN = {
42
+ name: "Volt",
43
+ symbol: "VOLT",
44
+ logo: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%2316e0ff'/%3E%3Cpath d='M18 4 8 18h7l-2 10 11-15h-7z' fill='%23062a3a'/%3E%3C/svg%3E",
45
+ } as const;
46
+
47
+ export interface SkuPrice {
48
+ readonly coins?: number;
49
+ readonly usdg?: number;
50
+ }
51
+
52
+ export interface MockKit extends KitStoreBridge {
53
+ /** Dollars left (mock only). */
54
+ usdg(): number;
55
+ /** Earned, unclaimed coins left (mock only). */
56
+ earned(): number;
57
+ }
58
+
59
+ /** An in-memory Kit for local play and tests: starting balances, prices from game.ts. */
60
+ export function mockKit(
61
+ start: { earned: number; wallet: number; usdg: number },
62
+ prices: Readonly<Record<string, SkuPrice>>,
63
+ coin: { name: string; symbol: string; logo: string | null } = VOLT_COIN,
64
+ ): MockKit {
65
+ let earned = start.earned;
66
+ let wallet = start.wallet;
67
+ let usdg = start.usdg;
68
+ const owned = new Map<string, number>();
69
+ let spends = 0;
70
+ /** Earned first, then the wallet: the Kit's order. */
71
+ const spend = (amount: number, reason: string) => {
72
+ if (!Number.isInteger(amount) || amount <= 0 || earned + wallet < amount) throw new Error(`cannot spend ${amount} for ${reason}`);
73
+ const fromEarned = Math.min(earned, amount);
74
+ earned -= fromEarned;
75
+ wallet -= amount - fromEarned;
76
+ return { fromEarned, onChain: amount - fromEarned, key: `mock-spend-${++spends}` };
77
+ };
78
+ return {
79
+ usdg: () => usdg,
80
+ earned: () => earned,
81
+ store: {
82
+ owns: (id) => (owned.get(id) ?? 0) > 0,
83
+ count: (id) => owned.get(id) ?? 0,
84
+ async buy(id, opts) {
85
+ const p = prices[id];
86
+ if (!p) throw new Error(`no SKU "${id}"`);
87
+ const payUsdg = opts?.pay === "usdg" || (opts?.pay === undefined && p.coins === undefined);
88
+ if (payUsdg) {
89
+ if (p.usdg === undefined || usdg < p.usdg) throw new Error("not enough USDG");
90
+ usdg -= p.usdg;
91
+ } else {
92
+ if (p.coins === undefined) throw new Error(`"${id}" has no coin price`);
93
+ spend(p.coins, id);
94
+ }
95
+ owned.set(id, (owned.get(id) ?? 0) + 1);
96
+ return { id };
97
+ },
98
+ },
99
+ coins: {
100
+ name: () => coin.name,
101
+ symbol: () => coin.symbol,
102
+ logo: () => coin.logo,
103
+ balance: () => earned + wallet,
104
+ async spend(amount, reason) {
105
+ return spend(amount, reason);
106
+ },
107
+ },
108
+ };
109
+ }