@netforge/template-party 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,63 @@
1
+ # Extending ROMPHOUSE
2
+
3
+ ROMPHOUSE is the party co-op template of the Net Forge game engine. This file tells a coding agent how to change the game safely. `llms.txt` in this package has the full recipe book for this genre, and `llms-full.txt` adds the whole `src/game.ts` as a worked example.
4
+
5
+ ## The one file to edit
6
+
7
+ Change the game by editing `src/game.ts` only. It holds declarations, not engine code. client/names.ts holds the display labels for weapon, upgrade and earn ids. In a downloaded game folder, labels players read live in src/names.json. Everything else (the client, the manifest, the store hooks, the simulation, the netcode) is template machinery.
8
+
9
+ ## Import only @netforge/engine
10
+
11
+ `src/game.ts` imports from `@netforge/engine` and nothing else. The template's game imports:
12
+
13
+ ```ts
14
+ import { achievement, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
15
+ ```
16
+
17
+ Also available for this genre: `economy`, `metaUpgrade`. `earn` has `earn.rule(...)` and `earn.stake(...)`; `store` has `store.sku(...)`. `system(id, ctx => ...)` gets the systems API described in `llms.txt`. The renderer, input, netcode and physics live elsewhere and are not reachable from game code.
18
+
19
+ ## Rules the build enforces
20
+
21
+ - Ids are lower_snake_case.
22
+ - Every game declares at least one `earn.rule(...)` (mandatory earn) and one `earn.stake(...)` (the buy-in run), on one top-level line. A game without either does not build.
23
+ - Asset ids are library ids such as `style.lowpoly.turret`, never URLs or file paths, and must exist in the asset library.
24
+ - `game({ template: "party" })` with one room in `game({ field })`: a floor and four walls.
25
+ - A party game needs one enemy prefab, one weapon and `players.weapon` (or keepers).
26
+ - Drops must be pickup prefabs. At most 15 weapons (evolutions count) and 8 upgrades.
27
+ - `game({ style })` is the art style the creator picked (lowpoly, toon, ps1, pixel, voxel or handdrawn). Keep it.
28
+ - Keep the store SKUs the client is wired to, at their prices: revive, skin_disco and the lucky_charm gear. "stake" is reserved and is never a SKU id.
29
+
30
+ ## Validate
31
+
32
+ - With the MCP server (`npx -y @netforge/mcp`, bin `netforge-mcp`): call `netforge_validate_game` on the game. It returns one line per problem.
33
+ - In this package: `npx vitest run` runs `test/harness.test.ts`, `test/host.test.ts`, `test/store.test.ts`. A bad declaration throws `GameDefinitionError` with a line you can act on.
34
+
35
+ ## Playtest
36
+
37
+ - With the MCP server: call `netforge_run_playtest`.
38
+ - In code: `import { engine } from "@netforge/engine"` and run `engine.test.run({ ticks: 30 * 180, bots: 4, assert: [] })`. It reports kills, wave (1 plus the rounds played), maxLevel, earned by rule, deaths, runs won and lost, softlock and ms per tick. With `bot: "human"` a human-like bot plays and `report.fun` holds the fun metrics the gate scores.
39
+
40
+ ## Play locally
41
+
42
+ ```sh
43
+ mkdir -p my-game/src && cd my-game
44
+ npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-party
45
+ cp node_modules/@netforge/template-party/src/game.ts src/
46
+ echo '{"template":"party"}' > netnet.json
47
+ npx kit-play # opens http://127.0.0.1:4663
48
+ ```
49
+
50
+ The MCP tool `netforge_play_locally` does the same. Coins are play money; nothing pays out and no chain or wallet is involved. Edit `src/game.ts` and run it again to try a change.
51
+
52
+ ## What not to do
53
+
54
+ - Do not write renderers, netcode, physics or bot AI. The template already has them.
55
+ - Do not use asset URLs or file paths. Use library ids.
56
+ - Do not import anything other than `@netforge/engine` in `src/game.ts`.
57
+ - Do not edit `client/`, `src/manifest.ts` or `src/store.ts`.
58
+ - Do not rename or reprice the store SKUs the client is wired to.
59
+ - Do not remove the `show` system's breather or the finale; add minigames to its list instead.
60
+
61
+ ## Docs
62
+
63
+ https://forge.netnet.capital/docs/templates/party
package/CLAUDE.md ADDED
@@ -0,0 +1,11 @@
1
+ # CLAUDE.md
2
+
3
+ Read AGENTS.md before changing this game.
4
+
5
+ The five hard rules:
6
+
7
+ 1. Edit only `src/game.ts`.
8
+ 2. Import only `@netforge/engine`.
9
+ 3. Ids are lower_snake_case.
10
+ 4. Declare at least one `earn.rule(...)` and exactly one `earn.stake(...)`.
11
+ 5. No renderers, netcode, physics or asset URLs.
package/README.md CHANGED
@@ -1,51 +1,75 @@
1
- # ROMPHOUSE (party co-op)
1
+ # @netforge/template-party
2
2
 
3
- Two to eight friends in one big playroom, five quick rounds a show. Each
4
- round is a minigame with one goal for the whole team: pop the party
5
- poppers before the timer, crash every gift box, catch the runaway pup, hold
6
- the floor against a tide, and as the finale burst the Big Balloon. Win
7
- three and the show is a hit. Weapons fire by themselves, so every round is
8
- about where you stand and who you help up.
3
+ A party game template for Net Forge: 2 to 8 friends in one playroom, five quick minigame rounds a show (ROMPHOUSE).
9
4
 
10
- | Path | What |
11
- |---|---|
12
- | `src/game.ts` | The game, as the model writes it (declarations only): the playroom, the party crowd, weapons, the show system with its ROUNDS list, earn, store. |
13
- | `src/manifest.ts` | Loads and compiles it; `partyAdapter()` for the room server and client. Room template `party`. |
14
- | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): a revive (50 coins), a cosmetic (300 coins) and the lucky charm (losable gear, 150 coins), shown under the token's own symbol and logo; mock until the token is live. |
15
- | `client/` | The browser client: three.js scene, the playroom (`client/stage.ts`: a checkered dance floor, candy-striped walls, a ring of party lights, an x-ray cut so a wall never hides you), HUD (ROUND n and the round's clock), input (keys, gamepad, touch), game feel. |
16
- | `test/` | Playtest bots through `engine.test.run`, and the store. |
17
- | `llms.txt` | How to change the game (for agents). |
18
-
19
- ## How it plays on the server
20
-
21
- - Server-authoritative at 30 Hz on the survivor machinery (auto-firing
22
- weapons, stars and level-up cards, co-op downs and revives, keepers, a
23
- boss with telegraphed attacks). Your own player is predicted.
24
- - One room: a floor and four walls in the sim (engine `field.floors` and
25
- `field.walls`), bigger than the screen so every crowd arrives off screen.
26
- - The show is a system in game.ts the model can read and change: a list of
27
- minigames (start, won, lastsOut), shuffled each show with the finale
28
- last, a breather between rounds that clears the floor and brings back
29
- anyone who is out.
30
- - Earn: a round won, four rounds won, a clean sweep, and 70 kills by one player, once
31
- per show each, on the same pay structure as EMBERWATCH. The room server
32
- is the referee.
33
-
34
- ## Run it
35
-
36
- This template runs on your own computer with play money through
37
- `kit play` (`@netforge/kit-play`). From the repository root:
5
+ Each round is a minigame with one goal for the whole team: pop the party poppers before the timer, crash every gift box, catch the runaway pup, hold the floor against a tide, and, as the finale, burst the Big Balloon. Win three and the show is a hit. Weapons fire by themselves, so every round is about where you stand and who you help up.
6
+
7
+ The room server is authoritative at 30 Hz on the survivor machinery (auto-firing weapons, stars and level-up cards, co-op downs and revives, keepers, a boss with telegraphed attacks). Your own player is predicted. The playroom is one floor and four walls, bigger than the screen, so every crowd arrives off screen.
8
+
9
+ The show is the `show` system in `src/game.ts`: a list of minigames (start, won, lastsOut), shuffled each show with the finale last, with a breather between rounds that clears the floor and brings back anyone who is out.
10
+
11
+ Earn: a round won, four rounds won, a clean sweep, and 70 kills by one player, once per show each. Seat stakes and real-token earn run only on the hosted Net Forge server.
12
+
13
+ ## Install
38
14
 
39
15
  ```sh
40
- pnpm install --frozen-lockfile && pnpm build
41
- mkdir -p play-party/src && cp packages/template-party/src/game.ts play-party/src/
42
- echo '{"template":"party"}' > play-party/netnet.json
43
- node packages/kit-play/dist/cli.js play-party # opens http://127.0.0.1:4663
16
+ npm install @netforge/template-party
44
17
  ```
45
18
 
46
- Edit `play-party/src/game.ts` and run it again to try a change. Real-token
47
- earning, stakes and claims run only on the hosted Net Forge server.
19
+ You normally do not import this package yourself. `kit play` runs it on your own computer, and the Net Forge MCP server and studio copy its `src/game.ts` as the starting point for a new game.
20
+
21
+ ## Play it locally
22
+
23
+ ```sh
24
+ mkdir -p my-game/src && cd my-game
25
+ npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-party
26
+ cp node_modules/@netforge/template-party/src/game.ts src/
27
+ echo '{"template":"party"}' > netnet.json
28
+ npx kit-play # opens http://127.0.0.1:4663
29
+ ```
30
+
31
+ Coins are play money and nothing pays out. Real-token earning, stakes and claims run only on the hosted Net Forge server.
32
+
33
+ ## Change the game
34
+
35
+ Rounds run 40 seconds and three wins make the show a hit. Give each round more time and ask for four wins:
36
+
37
+ ```ts
38
+ const ROUND_SECONDS = 50;
39
+ const BREATHER = 6;
40
+ const HIT_AT = 4;
41
+ ```
42
+
43
+ Save `src/game.ts` and run `npx kit-play` again.
44
+
45
+ ## Files
46
+
47
+ | Path | What |
48
+ |---|---|
49
+ | `src/game.ts` | The game, declarations only: the playroom, the party crowd, weapons, the `show` system with its rounds, earn, store. |
50
+ | `src/manifest.ts` | Loads and compiles the game; `partyAdapter()` and `partyGame()` for the room host and the client. |
51
+ | `src/store.ts` | Store hooks on the Kit's names: a revive (50 coins), a cosmetic (300 coins) and the lucky charm gear (150 coins). |
52
+ | `client/` | The browser client: three.js scene, the playroom stage, HUD, input (keys, gamepad, touch), game feel. `client/names.ts` holds display labels. |
53
+ | `test/` | Playtest bots through `engine.test.run`, the local host and the store. |
54
+ | `llms.txt` | How to change the game, for agents. |
55
+
56
+ ## Controls
57
+
58
+ Keys: WASD or arrows move, Space dash, 1 2 3 pick a card. Touch: drag on the left half, the DASH button, tap a card. The backquote key shows stats. Open the Invite link in another tab or on a phone on the same network to play together (`?room=<id>`).
59
+
60
+ ## For AI agents
61
+
62
+ Read `AGENTS.md` and `llms.txt` in this package before changing the game. `llms-full.txt` adds the whole `src/game.ts` as a worked example.
63
+
64
+ Agents can also drive this template through the Net Forge MCP server: run `npx -y @netforge/mcp` (package `@netforge/mcp`, bin `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`.
65
+
66
+ 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`.
67
+
68
+ ## Docs
69
+
70
+ - https://forge.netnet.capital/docs/templates/party
71
+ - `llms.txt` and `llms-full.txt` in this package (also exported as `@netforge/template-party/llms.txt` and `@netforge/template-party/llms-full.txt`)
72
+
73
+ ## License
48
74
 
49
- Open the Invite link in another tab or on a phone on the same network to
50
- play together (`?room=<id>`). Keys: WASD or arrows, Space dash, 1 2 3 pick.
51
- Touch: drag on the left half, DASH button, tap a card. ` shows stats.
75
+ MIT. See `LICENSE` and `NOTICE`.
package/client/main.ts CHANGED
@@ -160,7 +160,7 @@ async function main(): Promise<void> {
160
160
  const joinMs = performance.now() - joinStart;
161
161
  if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("That room was full, started a new one"), 500);
162
162
  if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("A new version is live. Reload to update."), 800);
163
- history.replaceState(null, "", `${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${autopilot ? "&autopilot=1" : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
163
+ history.replaceState(null, "", `${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}${autopilot ? "&autopilot=1" : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
164
164
 
165
165
  // The engine's feel kit: hit-stop clock, trauma shake, flash, knockback, numbers, sound, rumble.
166
166
  const feelParam = params.get("feel");
@@ -233,7 +233,7 @@ async function main(): Promise<void> {
233
233
  revive: () => void paidRevive(),
234
234
  getUp: () => input.getUp(),
235
235
  invite: () => {
236
- const url = `${location.origin}${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
236
+ const url = `${location.origin}${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}`;
237
237
  void navigator.clipboard?.writeText(url).then(
238
238
  () => hud.toast("Invite link copied"),
239
239
  () => hud.toast(url),
package/llms-full.txt ADDED
@@ -0,0 +1,716 @@
1
+ # Net Forge party co-op template (ROMPHOUSE)
2
+
3
+ > A party game for 2 to 8 friends in one big playroom: a show of five
4
+ > quick rounds, each a different minigame with one goal for the whole
5
+ > team. Win three and the show is a hit. You change the game by editing
6
+ > src/game.ts only. The template already has the playroom (floor and
7
+ > walls), weapons that fire by themselves, XP stars, level-ups, the boss
8
+ > machinery, co-op revive, netcode, rendering, touch controls, earn and the
9
+ > store. Do not write renderers, netcode or physics.
10
+
11
+ ## Files
12
+
13
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
14
+ game(), prefab(), weapon(), upgrade(), system(), earn.rule(),
15
+ earn.stake(), store.sku(), keeper(), achievement(), mission(), title().
16
+ - packages/engine/GAME-DESIGN.md: the design rules the playtest gate scores
17
+ (a readable opening, off-screen spawns, i-frames, telegraphs, fast levels).
18
+ - client/names.ts: display names for weapon, upgrade and earn ids (labels).
19
+ - Models come from the asset library (real CC0 models; the full list of ids is in your instructions). client/library.ts holds the fallback shapes drawn until a model loads.
20
+ - Everything else is template machinery.
21
+
22
+ ## Rules the build enforces
23
+
24
+ - Asset ids look like "style.lowpoly.slime_pink", 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
+ - game({ template: "party" }) and one room in game({ field }): a floor and four walls.
27
+ - Ids are lower_snake_case.
28
+ - Every game declares at least one earn.rule (mandatory earn) and one earn.stake (the buy-in run).
29
+ - A party game needs one enemy prefab, one weapon and players.weapon (or keepers).
30
+ - Drops must be pickup prefabs. At most 15 weapons (evolutions count) and 8 upgrades.
31
+ - Run `pnpm --filter @netforge/template-party test` (playtest bots).
32
+
33
+ ## Units
34
+
35
+ Seconds, metres, metres per second, hit points. Tick rate 30 Hz. Players
36
+ run at 7 m/s; keep chasers at 3-4 m/s and fast ones under 6.
37
+
38
+ ## The playroom (game({ field }))
39
+
40
+ const ROOM = 30;
41
+ field: {
42
+ halfSize: ROOM + 2,
43
+ pillars: [[12, 12, 1.6], ...], // party tables: [x, z, radius]
44
+ floors: [[0, 0, ROOM, ROOM]], // the floor: [x, z, halfX, halfZ]
45
+ walls: [[0, ROOM + 1, ROOM + 2, 1], ...], // the four walls: [x, z, halfX, halfZ]
46
+ }
47
+ The room is bigger than the screen (60 m across against about 40 m in
48
+ view), so spawnEdge can always bring a crowd in just off screen. Players
49
+ start at [0, 0]. Walls and tables stop players, enemies and bolts.
50
+
51
+ ## The show (the "show" system in game.ts)
52
+
53
+ The show is a system you can read and change:
54
+
55
+ - ROUNDS is a list of minigames: { name, seconds, start(), won(), lastsOut }.
56
+ start() runs as the round opens (spawn its crowd, scatter its props,
57
+ start a trickle with every(...)). won() is checked every half second, and
58
+ true ends the round early as won. At the bell the round is won if won()
59
+ holds, or, for lastsOut rounds, if nobody went down.
60
+ - The first four rounds are shuffled on the sim's random each show; the
61
+ last (the Big Balloon) is always the finale.
62
+ - Every won round emits round.won (earn rules count it); every round's end
63
+ emits wave.cleared (the HUD's ROUND n); a clean sweep emits show.perfect.
64
+ Between rounds (BREATHER seconds) the leftovers are cleared and anyone
65
+ who is out comes back.
66
+ - HIT_AT rounds won make the show a hit (end("won")); fewer, it ends
67
+ "lost". Either way the end screen shows and the next show starts.
68
+ - Something must happen every few seconds in every round: a round with 30
69
+ quiet seconds fails the playtest (softlock). Give each round a trickle.
70
+
71
+ ### Add a minigame
72
+ {
73
+ name: "DODGEBALL",
74
+ seconds: 35,
75
+ lastsOut: true, // won if nobody goes down
76
+ start: () => {
77
+ spawnEdge("spritzer", { count: 6 + party() });
78
+ every(4, () => { if (current === 5) spawnEdge("spritzer", { count: 2 }); });
79
+ },
80
+ won: () => false,
81
+ },
82
+ Append it to ROUNDS (its index is its `current` value) and include it in the
83
+ shuffle (the shuffle leaves the last entry, the finale, where it is).
84
+ Counters you can use: kills (enemy kills this round), downs (players down
85
+ this round), alive("prefab"), and on("<prefab>.killed") or
86
+ on("miniboss.killed") for a flag.
87
+
88
+ ## Going down
89
+
90
+ players: { downed: { seconds: 8, crawl: 0.5, selfRevives: 2, respawn: 3 } }
91
+ A party forgives: a short down, a crawl, two free get-ups a show (Space),
92
+ then back in 3 s beside a friend. Everyone out returns between rounds.
93
+
94
+ ## Common changes
95
+
96
+ ### Make it harder or easier
97
+ Change a round's goal (poppers(), the scatter count), its seconds, the
98
+ trickle sizes, or HIT_AT. Easier also: players.hitIframes (0.8 s),
99
+ players.magnet, more selfRevives.
100
+
101
+ ### A longer or shorter show
102
+ Add or remove ROUNDS entries (keep 3 to 8). ROUND_SECONDS sets the default
103
+ length; each round may set its own.
104
+
105
+ ### Add an enemy
106
+ prefab("jester", { model: "style.lowpoly.blob_wizard", health: 30, speed: 3.2, radius: 0.4, damage: 6, ai: "chase", drops: ["star_small"] });
107
+ then bring it into a round's start() with spawnEdge("jester", { count: 8 }),
108
+ and add its id to the sweep list so it is cleared between rounds.
109
+ ai is the enemy's role. Every big hit is telegraphed on the ground first:
110
+ - "chase" (default), "tank" (a chaser; give it mass), "idle"/"none" (stands still)
111
+ - "runner": aims where you will be. Fast, fragile.
112
+ - "charger": stops inside 6 m, shows a lane 0.55 s, then charges along it.
113
+ - "ranged": keeps its distance, shows an aim line, fires a slow dodgeable shot
114
+ (only with a clear line: walls and tables are cover).
115
+ - "bomber": walks up, arms a 1 s fuse circle, explodes (hurts enemies too).
116
+ - "shielder" / "healer": every few seconds shields or heals nearby allies.
117
+ - "summoner": keeps away and calls adds: attack: { summon: "fizz", count: 2 }.
118
+ - "flee" (a carrier to catch, like the party pup), "wander".
119
+
120
+ ### Add elites
121
+ An elite is x6 health, x1.5 damage, x5 XP and a gold rim, and drops a chest:
122
+ spawnEdge("wobbler", { count: 1, elite: true }) in a round's start().
123
+
124
+ ### Add a weapon
125
+ weapon("frost_ring", {
126
+ kind: "nova", damage: 6, cooldown: 0.8, range: 5, knockback: 8,
127
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { knockback: 3 }],
128
+ });
129
+ kinds:
130
+ - "nova": a burst in range; "aura": a field that burns what is inside, every cooldown
131
+ - "bolt": count projectiles at the nearest enemy (speed, pierce)
132
+ - "orbit": count blades at range metres, speed rad/s
133
+ - "cone": a sweep of spread degrees where the keeper faces (360: all around)
134
+ - "chain": strikes the nearest, then jumps count times, each within speed m
135
+ - "meteor": count circles of blast m land on enemies in range, 0.6 s after showing
136
+ - "boomerang": flies out range m and back, hitting everything both ways
137
+ levels: what each level changes from the one before (5-8 levels). A number
138
+ adds (count: 1, pierce: 2, cooldown: -0.1 s), a percent scales ("+40%").
139
+ Cards show these changes ("+1 projectile, -20% cooldown"). It is offered at
140
+ level-up automatically. Add a label in client/names.ts.
141
+
142
+ ### Add an evolution
143
+ weapon("frost_ring", { ..., evolve: { into: "blizzard", requires: "iron_skin" } });
144
+ weapon("blizzard", { kind: "nova", damage: 20, cooldown: 0.6, range: 9, knockback: 12 });
145
+ At max level, with the passive "iron_skin" taken, the "blizzard" card always
146
+ shows; it replaces frost_ring in its slot. Evolutions are never offered on
147
+ their own. Give them about x1.7 damage and a new shape (360 degrees, double
148
+ radius, more count). Pair each base weapon with a different passive.
149
+
150
+ ### Add a passive
151
+ upgrade("iron_skin", { stat: "armor", step: 1, maxLevel: 4 });
152
+ stats: speed, maxHealth, magnet, might, haste, regen, armor, area (bigger
153
+ novas, auras, cones, meteors and orbits), amount (more projectiles, blades,
154
+ jumps and meteors). Up to 8. maxLevel caps how often it is offered.
155
+
156
+ ### Add pickups and loot
157
+ prefab("cake_slice", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
158
+ A boss's hoard: pickup: { effect: "chest", amount: 3, evolve: true }. Bosses
159
+ drop it instead of the plain chest. If the player holds a weapon and already
160
+ has its paired passive, that weapon jumps to max level, so its evolution is
161
+ on the cards.
162
+ effects: heal (amount: fraction of max health, or hit points above 1),
163
+ vacuum (every gem on the floor comes to you), bomb (clears the screen's
164
+ non-boss enemies), chest (amount card picks; a ready evolution shows first).
165
+ Drop them with a chance: drops: ["star_small", { id: "cake_slice", chance: 0.02 }].
166
+ Elites always drop the game's chest; bosses drop a heal, a vacuum and a chest.
167
+ Above 300 gems on the floor, new gems merge into the nearest one.
168
+
169
+ ### Add props (breakables)
170
+ prefab("gift_box", { model: "style.lowpoly.crate", health: 30, speed: 0, radius: 0.55, prop: true, drops: ["star_mid", { id: "cake_slice", chance: 0.2 }] });
171
+ then scatter("gift_box", 20) from a round's start() (props land on the floor, off every screen). Props never move, never hurt
172
+ and never count as enemies.
173
+
174
+ ### Add events and moments
175
+ In a round's start() or the show's next()/finish():
176
+ on("round.won", () => { if (random() < 0.3) { announce("ENCORE", { tone: "reward" }); xpBoost(2, 15); } });
177
+ announce tones: info, danger, reward. Events you can key on (and earn rules
178
+ too): round.won, show.perfect, miniboss.killed, player.downed,
179
+ player.revived, chest.collected, "<prefab>.killed". clear("popper")
180
+ removes a prefab at once (no kills or drops).
181
+
182
+ ### Cards
183
+ Weighted: an owned weapon's next level 8, a new weapon 10 (while a slot is
184
+ free), a passive 4 (7 once weapon slots are full); a ready evolution always
185
+ shows. players.rerolls (default 1) rerolls per run (R); Skip (X) trades the
186
+ cards for a small heal. When everything is maxed, a level heals and gives
187
+ teammates a little XP.
188
+
189
+ ### Add a boss
190
+ prefab("disco_dragon", {
191
+ model: "style.lowpoly.dragon_whelp", appearance: { tint: "#c9a7ff", scale: 1.4 }, health: 800, speed: 2.4, radius: 1.3, damage: 14,
192
+ boss: { attacks: ["slam", "leap"], rank: "mini" },
193
+ drops: ["star_large"],
194
+ });
195
+ Spawn it in a round's start() with spawnEdge("disco_dragon", { count: 1 }) and
196
+ win the round on on("miniboss.killed"). Attacks (all telegraphed): slam
197
+ (circle around it), dash (11 m lane), leap (lands on a circle at you), rain
198
+ (circles near players), ring (14 shots outward), fan (7 shots at you), summon
199
+ (adds). Near attacks (slam, leap) keep it after the players; a boss that only
200
+ throws from afar can be ignored by a party that will not chase it. Boss
201
+ health scales with players present (players.bossHealthPerPlayer, 0.35 here).
202
+
203
+ ### Add an earn rule
204
+ earn.rule("three_wins", { when: "round.won", n: 3, pay: 10, oncePer: "run" });
205
+ A run is one show. Events: round.won and show.perfect (the show emits them), enemy.killed (credits the killer),
206
+ boss.killed (everyone present), player.downed, player.revived (credits the
207
+ reviver), player.levelup, run.won, run.lost, and anything you emit().
208
+ Each rule pays once per run per player, in coins; the room server is the referee.
209
+
210
+ ### The buy-in run (required)
211
+ earn.stake({ min: 20, max: 200, multiple: 2 });
212
+ Every game declares one, on one top-level line; a game without it does not
213
+ build. A player stakes coins at the start of a run (the Kit spends them as
214
+ coins.spend(amount, "stake"), then the client calls conn.stake()). The room
215
+ fixes the stake when the next run starts. A staked run pays its normal earn
216
+ plus a share of stake x multiple: the share is the part of the run's most
217
+ earn the player makes. What it does not win back stays in the reward pool.
218
+ multiple is 1 to 2. "stake" is reserved: never a SKU or meta upgrade id.
219
+
220
+ ### Losable gear (optional)
221
+ store.sku("lucky_charm", { coins: 150, grants: { gear: "lucky_sock", loseBelow: 0.25 } });
222
+ Gear is owned in the player's profile and sells for coins only. It brings one
223
+ level of its declared upgrade into every staked run, and is lost when that run
224
+ makes less than loseBelow of its most earn. Its coins stay in the reward pool.
225
+
226
+ ### Coins: earn and the store
227
+
228
+ Every game has coins: 1 coin = 1 of the game's own token, and players see
229
+ them under the token's own name, symbol and logo (never "coins" or "tokens"
230
+ as a label). Earn rules pay coins (`pay` is whole coins). The recommended
231
+ coin model (the default, no declaration) sells cosmetics, unlocks and
232
+ in-run consumables, never permanent stat power:
233
+
234
+ store.sku("revive", { coins: 50 }); // in-run: used while downed
235
+ store.sku("skin_disco", { coins: 300, grants: { cosmetic: 1 } }); // a look, owned for good
236
+
237
+ Keep them and the lucky_charm gear exactly: the client is wired to them (price, look id and gear). Owned
238
+ looks and unlocks are saved on the room server in the player's profile. Players spend
239
+ earned coins first (instantly, no wallet prompt), then their wallet. Before
240
+ the game launches coins are play money; the Kit switches by itself, so
241
+ nothing in game.ts depends on it. Price in coins against what a run pays
242
+ (the four rules here pay up to 80 a run): a revive is under a run, a
243
+ cosmetic a few runs.
244
+
245
+ ### Sell a keeper unlock (a character, a starting loadout, a sidegrade)
246
+ keeper("mc", { name: "MC", text: "Streamers and a party hat.", weapon: "streamer", passive: "party_hat" }); // no unlock
247
+ store.sku("unlock_mc", { coins: 400, grants: { keeper: "mc" } });
248
+ A keeper is earned (unlock: an achievement) or bought (a SKU), never both:
249
+ the build refuses a sold keeper that also declares an unlock. Never sell
250
+ the first keeper (everyone's free default). An unlock is a different way to
251
+ play, never more power. Before launch coins are play money and buy only
252
+ in-run items; looks and unlocks open at launch.
253
+
254
+ ### Cosmetics only ("no pay to win")
255
+ economy({ store: "cosmetics" });
256
+ Then every SKU must be a look (grants: { cosmetic: n }); drop revive.
257
+
258
+ ### Permanent upgrades for coins (opt-in meta upgrades)
259
+ economy({ metaUpgrades: true });
260
+ metaUpgrade("hearth", { stat: "maxHealth", step: 0.05, levels: 3, cost: 120, growth: 2 }); // 120, 240, 480 coins
261
+ metaUpgrade("stride", { stat: "speed", step: 0.03, levels: 3, cost: 100, growth: 1.8 });
262
+ Only when the creator asks for permanent upgrades. Stats: speed, maxHealth,
263
+ magnet, might, haste, area. The engine refuses more than +15% to one stat
264
+ or +30% in all, more than 5 levels, or growth under 1.5, and applies a
265
+ player's levels at the start of every run. The store shows each level.
266
+
267
+ ### Add a keeper (a playable character)
268
+ keeper("juggler", { name: "Juggler", text: "Spinning plates and a wider spin.", model: "style.lowpoly.blocky_k", tint: "#7ad7ff", weapon: "spinning_plates", passive: "disco_ball", unlock: "first_hit" });
269
+ A keeper starts every run with its weapon and its passive at level 1;
270
+ players pick one before their first level and on the end screen. At most
271
+ 15; the first declared keeper is everyone's default, and at least one
272
+ keeper has no unlock (declare a free one first). Keepers unlock with
273
+ achievements, or with coins through a SKU with grants: { keeper }. The room
274
+ server checks every pick against the player's saved unlocks.
275
+ With keepers declared, players.weapon is optional.
276
+
277
+ ### Add achievements, daily goals and end-screen titles
278
+ achievement("evolver", { title: "Evolved", text: "Evolve a weapon.", when: "player.evolved" });
279
+ mission("elite", { text: "Kill an elite", when: "elite.killed" });
280
+ title("butcher", { text: "The Butcher", most: "kills" });
281
+ Achievements and missions count a game event per player in one run (n
282
+ times) and pay nothing (coins come only from earn rules). The room server
283
+ grants them from the run and saves them per player (wallet, or guest until
284
+ sign-in), so they follow the player to every device. The HUD shows
285
+ three missions a day. Titles rank players at the end of a run by most or
286
+ least of: kills, damage, revives, downs, level, pickups. Events to count:
287
+ anything you emit, "<prefab>.killed" ("party_pup.killed"),
288
+ "elite.killed", "player.evolved", "player.revived", "chest.collected",
289
+ "miniboss.killed", "round.won", "show.perfect".
290
+
291
+ ### Co-op that feels shared
292
+ players: { share: "nearby" } // default: half of each gem to teammates within 20 m
293
+ players: { share: "all" } // or "collector", or { mode, fraction, radius }
294
+ players: { reviveBuff: { seconds: 5, damage: 1.3, speed: 1.15 } } // the reviver burns hotter
295
+ players: { downed: { seconds: 8, crawl: 0.5, selfRevives: 2, respawn: 3 } } // this game: forgiving
296
+ players: { lateJoinBehind: 2 } // a late joiner starts 2 levels under the team, cards waiting
297
+ players: { crit: { chance: 0.08, multiplier: 2 } } // crits show as gold numbers
298
+ The end screen votes "Play again" (everyone ready starts the next run; it
299
+ starts by itself after 10 s) and shows each keeper's build, damage per
300
+ weapon, what downed them, coins and titles.
301
+
302
+ ### Players per room
303
+ players.max is 8 here, the most the hosted Net Forge room server seats
304
+ in a party room. Fewer is fine; more is refused.
305
+
306
+ ## Models and appearances (the asset library)
307
+
308
+ Every model is a real CC0 model from the asset library, by id. Enemies are
309
+ drawn about 3.2 hit radii tall, keepers 1.8 m; animated ones walk, run and
310
+ die by themselves. An appearance dresses a model into your own original:
311
+ appearance: { tint: "#rrggbb", accent: "#rrggbb", scale: 0.4 to 3, parts: up to 4 }.
312
+
313
+ ### Make the crowd look like robots
314
+ prefab("popper", { model: "style.lowpoly.robot_walker", ... });
315
+ prefab("fizz", { model: "style.lowpoly.robot_flyer", ... });
316
+ Keep the ids or rename them everywhere (ROUNDS and the sweep list too), and
317
+ retell it in names.json.
318
+
319
+ ### A hero described by the creator
320
+ players: { prefab: "style.lowpoly.worker", appearance: { tint: "#2e7d32", accent: "#3b2a1a", parts: ["mustache", "hat_cap"] }, ... }
321
+ Name it something new in names.json. A creature the library lacks: the
322
+ nearest base plus parts, e.g. model "style.lowpoly.shiba" with
323
+ appearance { tint: "#5ec8ff", parts: ["antenna", "tail_bolt"] }.
324
+
325
+ ## Look and feel (engine render stack and feel kit)
326
+
327
+ How the game looks and how hits feel are declarations too. They are
328
+ presentation only (never the simulation), so tune them freely. The full
329
+ reference is packages/engine/llms.txt.
330
+
331
+ ### Change the look
332
+ game({ ..., look: { preset: "dusk", bloom: 1.4, fog: 0.3, tint: "#ffd9a0" } });
333
+ presets: "night" (default), "dusk", "day", "studio". Also shadows (off here unless
334
+ look.shadows is true), outline,
335
+ exposure, saturation, contrast, vignette.
336
+
337
+ ### Make a death or a hit feel bigger
338
+ prefab("brute", { ..., feel: { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06, color: "#ff4d5e" } });
339
+ Without feel, big enemies (bosses, radius 0.5 m or more) burst, scorch and
340
+ shake, and small ones burst, splat and crunch.
341
+
342
+ ### Make a weapon's impacts feel different
343
+ weapon("frost_ring", { ..., feel: { particles: "magic", decal: "ring", color: "#7ad7ff", shake: 0.1 } });
344
+ Novas use it when they go off; bolts and lances when they hit.
345
+
346
+ ### Heavier or lighter knockback
347
+ weapon knockback (m/s) pushes enemies, divided by their mass. Knocked
348
+ enemies stagger, slide, bounce off walls and pillars and shove the
349
+ enemies behind them. Bolts stop at walls and pillars.
350
+
351
+ ## Systems API
352
+
353
+ every(seconds, n => ...), after(seconds, fn), onTick(fn), spawn(prefab, [x,y,z], { elite? }),
354
+ spawnPickup(prefab, [x,y,z]), announce(text, { tone }), bossAlive(),
355
+ pauseDirector(seconds), surge(multiplier, seconds), xpBoost(multiplier, seconds),
356
+ scatter(prefab, count), clear(prefab), upcoming(label, seconds) (a HUD countdown:
357
+ upcoming("THE BELL WARDEN", 240)),
358
+ spawnEdge(prefab, { count, around?, side?, spread?, scale? }) (just off screen;
359
+ side: "any" | "ahead" | "behind" | "north" | "south" | "east" | "west"),
360
+ spawnRing(prefab, { count, radius, around? }), alive(prefab?), players(),
361
+ emit(event), on(event, players => ...), respawnDead(), end("won" | "lost"),
362
+ onScreen(x, z) (true when a player who is up can see that ground point),
363
+ random(), tick(), time().
364
+
365
+ ## Test
366
+
367
+ engine.test.run({ ticks: 30 * 180, bots: 4, assert: [...] }) plays the game
368
+ on the server sim with bots and reports kills, wave, maxLevel, earned by
369
+ rule, deaths, runs won and lost, softlock and ms per tick (wave is
370
+ 1 + rounds played). With
371
+ bot: "human" a human-like bot plays (200 ms reactions, kiting, a second per
372
+ card) and report.fun has the fun metrics: first threat, first level,
373
+ level at 1/3/6/9 minutes, downs before 1:00, enemies on screen at 0:30,
374
+ touches in minute 1, spawns on screen, survival. Net Forge's gate scores
375
+ them; `pnpm --filter @netforge/template-party fun` prints the table.
376
+
377
+ ## Worked example: src/game.ts
378
+
379
+ The whole game as the template ships it. Every change starts from this file.
380
+
381
+ ```ts
382
+ /**
383
+ * ROMPHOUSE: the party co-op template ("party co-op").
384
+ *
385
+ * Two to eight friends in one big playroom, five quick rounds a show. Each
386
+ * round is a different minigame with one goal for the whole team: pop the
387
+ * party poppers before the timer, crash every gift box, catch the runaway
388
+ * pup, hold the floor against a tide, and as the finale burst the Big
389
+ * Balloon. Win three of five and the show is a hit. Weapons fire by themselves, so every round
390
+ * is about where you stand and who you help up.
391
+ *
392
+ * This file is the whole game as the model writes it: declarations only,
393
+ * against the typed API in @netforge/engine. The rounds are the "show"
394
+ * system below: a list of minigames, each a few lines of start, check and
395
+ * finish. Everything else (sim, netcode, renderer, referee) is the
396
+ * template's machinery. Design rules: packages/engine/GAME-DESIGN.md.
397
+ */
398
+ import { achievement, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
399
+
400
+ /** The playroom: one 60 m room (its floor and four walls), with party tables to dodge round. */
401
+ const ROOM = 30;
402
+
403
+ export default game({
404
+ template: "party",
405
+ style: "lowpoly",
406
+ places: { playroom: { chunks: "places/playroom", spawn: [0, 1, 0] } },
407
+ field: {
408
+ halfSize: ROOM + 2,
409
+ pillars: [
410
+ [12, 12, 1.6], [-12, 12, 1.6], [12, -12, 1.6], [-12, -12, 1.6],
411
+ [0, 21, 2], [0, -21, 2], [21, 0, 2], [-21, 0, 2],
412
+ ],
413
+ floors: [[0, 0, ROOM, ROOM]],
414
+ walls: [[0, ROOM + 1, ROOM + 2, 1], [0, -ROOM - 1, ROOM + 2, 1], [ROOM + 1, 0, 1, ROOM], [-ROOM - 1, 0, 1, ROOM]],
415
+ },
416
+ look: { preset: "studio", bloom: 1.1, saturation: 1.2, vignette: 0.25 },
417
+ players: {
418
+ prefab: "style.lowpoly.blocky_c",
419
+ max: 8,
420
+ weapon: "confetti_pop",
421
+ xp: { base: 2, growth: 2, exponent: 2 },
422
+ magnet: 4.5,
423
+ hitIframes: 0.8,
424
+ share: "all",
425
+ crit: { chance: 0.1, multiplier: 2 },
426
+ reviveBuff: { seconds: 5, damage: 1.3, speed: 1.2 },
427
+ bossHealthPerPlayer: 0.35,
428
+ // A party forgives: a short down, two free get-ups a show, back in 3 s.
429
+ downed: { seconds: 8, crawl: 0.5, selfRevives: 2, respawn: 3 },
430
+ },
431
+ });
432
+
433
+ // The party crowd. Health in hit points, speed in m/s (players run at 7), damage per touch.
434
+ const GIFT = [{ id: "cake_slice", chance: 0.02 }, { id: "party_magnet", chance: 0.01 }];
435
+ prefab("popper", { model: "style.lowpoly.slime_pink", appearance: { tint: "#ff7ab8" }, health: 16, speed: 3.6, radius: 0.4, damage: 5, ai: "chase", drops: ["star_small", ...GIFT] });
436
+ prefab("fizz", { model: "style.lowpoly.bee", appearance: { tint: "#7affd4" }, health: 8, speed: 5.4, radius: 0.28, damage: 3, ai: "runner", drops: ["star_small", ...GIFT] });
437
+ prefab("wobbler", { model: "style.lowpoly.blob_ninja", appearance: { tint: "#c9a7ff" }, health: 50, speed: 2.5, radius: 0.55, damage: 9, ai: "tank", mass: 3, drops: ["star_mid"] });
438
+ prefab("spritzer", { model: "style.lowpoly.frog", appearance: { tint: "#5ec8ff", scale: 0.45 }, health: 20, speed: 2.8, radius: 0.4, damage: 4, ai: "ranged", attack: { damage: 7, range: 9, speed: 7 }, drops: ["star_small"] });
439
+ prefab("party_pup", { model: "style.lowpoly.pug", appearance: { tint: "#ffd36b" }, health: 70, speed: 6, radius: 0.45, damage: 0, ai: "flee", drops: [{ id: "party_chest", chance: 1 }] });
440
+ prefab("gift_box", { model: "style.lowpoly.crate", appearance: { tint: "#ff5a7a", accent: "#ffe066" }, health: 30, speed: 0, radius: 0.55, damage: 0, prop: true, drops: ["star_mid", { id: "cake_slice", chance: 0.2 }] });
441
+ // The Big Balloon: the show's boss round. Two telegraphed attacks and a burst of prizes.
442
+ prefab("big_balloon", {
443
+ model: "style.lowpoly.jelly_flyer",
444
+ appearance: { tint: "#ff5a7a", accent: "#ffe066", scale: 1.4 },
445
+ health: 650,
446
+ speed: 2.4,
447
+ radius: 1.3,
448
+ damage: 12,
449
+ // It bounces after you: a slam ring around it, and a leap that lands on a circle at you.
450
+ boss: { attacks: ["slam", "leap"], rank: "mini" },
451
+ drops: ["star_large"],
452
+ });
453
+
454
+ // Prizes: stars are XP; cake heals, the magnet pulls every star, the chest is a pick.
455
+ prefab("cake_slice", { model: "style.lowpoly.cell_heal", appearance: { tint: "#ffd1e8" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
456
+ prefab("party_magnet", { model: "style.lowpoly.crystal", appearance: { tint: "#7ad7ff" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "vacuum" } });
457
+ prefab("party_chest", { model: "style.lowpoly.chest", appearance: { tint: "#ffcf5a" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 1 } });
458
+ prefab("prize_hoard", { model: "style.lowpoly.chest", appearance: { tint: "#ff7ab8", scale: 1.5 }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 2, evolve: true } });
459
+ prefab("star_small", { model: "style.lowpoly.gem_small", appearance: { tint: "#ffe066" }, health: 1, speed: 0, kind: "pickup", value: 1 });
460
+ prefab("star_mid", { model: "style.lowpoly.gem_mid", appearance: { tint: "#ffb347" }, health: 1, speed: 0, kind: "pickup", value: 6 });
461
+ prefab("star_large", { model: "style.lowpoly.gem_large", appearance: { tint: "#ff7ab8" }, health: 1, speed: 0, kind: "pickup", value: 40 });
462
+
463
+ // Weapons fire by themselves. Cooldowns in seconds, ranges in metres.
464
+ weapon("confetti_pop", {
465
+ kind: "nova", damage: 12, cooldown: 1, range: 3.6, knockback: 3,
466
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { range: "+15%" }],
467
+ evolve: { into: "confetti_cannon", requires: "sugar_rush" },
468
+ });
469
+ weapon("streamer", {
470
+ kind: "bolt", damage: 13, cooldown: 0.6, range: 13, speed: 24, pierce: 1, knockback: 2,
471
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { pierce: 1 }, { count: 1 }],
472
+ evolve: { into: "streamer_storm", requires: "party_hat" },
473
+ });
474
+ weapon("spinning_plates", {
475
+ kind: "orbit", damage: 8, cooldown: 0.1, range: 2.4, count: 2, speed: 3.8, knockback: 3,
476
+ levels: [{ count: 1 }, { damage: "+30%" }, { range: "+20%" }, { count: 1 }, { damage: "+40%" }],
477
+ evolve: { into: "plate_carousel", requires: "disco_ball" },
478
+ });
479
+ weapon("pie_toss", {
480
+ kind: "meteor", damage: 34, cooldown: 2, range: 11, count: 1, blast: 1.8, knockback: 6,
481
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
482
+ });
483
+ weapon("limbo_bar", {
484
+ kind: "cone", damage: 16, cooldown: 1.1, range: 4, spread: 110, knockback: 6,
485
+ levels: [{ damage: "+30%" }, { spread: 40 }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
486
+ });
487
+ weapon("kazoo_chain", {
488
+ kind: "chain", damage: 15, cooldown: 1.3, range: 9, count: 2, speed: 4, knockback: 1,
489
+ levels: [{ count: 1 }, { damage: "+35%" }, { count: 1 }, { cooldown: "-20%" }, { count: 2 }],
490
+ });
491
+
492
+ // Evolutions: about x1.7 damage and a new shape. Never offered on their own.
493
+ weapon("confetti_cannon", { kind: "nova", damage: 36, cooldown: 0.8, range: 8, knockback: 9 });
494
+ weapon("streamer_storm", { kind: "bolt", damage: 30, cooldown: 0.4, range: 15, speed: 28, count: 6, pierce: 3, knockback: 3 });
495
+ weapon("plate_carousel", { kind: "orbit", damage: 18, cooldown: 0.1, range: 3.6, count: 6, speed: 4.6, knockback: 4 });
496
+
497
+ // Passive upgrades offered at level-up. Each evolution pairs with one.
498
+ upgrade("dance_shoes", { stat: "speed", step: 0.07, maxLevel: 5 });
499
+ upgrade("second_helping", { stat: "maxHealth", step: 20, maxLevel: 5 });
500
+ upgrade("party_hat", { stat: "amount", step: 1, maxLevel: 2 });
501
+ upgrade("sugar_rush", { stat: "haste", step: 0.06, maxLevel: 5 });
502
+ upgrade("disco_ball", { stat: "area", step: 0.1, maxLevel: 5 });
503
+ upgrade("lucky_sock", { stat: "might", step: 0.1, maxLevel: 5 });
504
+ upgrade("goodie_bag", { stat: "magnet", step: 0.8, maxLevel: 4 });
505
+
506
+ /** Seconds a round runs, the breather between rounds, and the rounds won that make the show a hit. */
507
+ const ROUND_SECONDS = 40;
508
+ const BREATHER = 6;
509
+ const HIT_AT = 3;
510
+ const SIDES = ["north", "south", "east", "west"] as const;
511
+ /** Everything a round brings in: cleared off the floor between rounds. */
512
+ const SWEEP = ["popper", "fizz", "wobbler", "spritzer", "party_pup", "gift_box", "big_balloon"];
513
+
514
+ system("show", ({ every, after, onTick, tick, players, spawnEdge, scatter, alive, clear, emit, announce, upcoming, respawnDead, random, on, end }) => {
515
+ // The five minigames, shuffled each show. start() runs as the round opens; won() is checked every half second
516
+ // (true ends it early), and at the bell the round is won if won() holds or `lastsOut` (nobody fell).
517
+ let kills = 0;
518
+ let downs = 0;
519
+ let pupCaught = false;
520
+ let balloonBurst = false;
521
+ const party = () => Math.max(1, players().length);
522
+ const poppers = () => 40 + 12 * (party() - 1);
523
+ interface Round {
524
+ readonly name: string;
525
+ readonly seconds: number;
526
+ start(): void;
527
+ won(): boolean;
528
+ readonly lastsOut?: boolean;
529
+ }
530
+ const ROUNDS: Round[] = [
531
+ {
532
+ name: "POP THE PARTY",
533
+ seconds: ROUND_SECONDS,
534
+ start: () => {
535
+ spawnEdge("popper", { count: 10 });
536
+ every(2, () => {
537
+ if (current === 0) spawnEdge(random() < 0.25 ? "fizz" : "popper", { count: 4 + party() });
538
+ });
539
+ },
540
+ won: () => kills >= poppers(),
541
+ },
542
+ {
543
+ name: "CRATE CRASH",
544
+ seconds: 40,
545
+ start: () => {
546
+ scatter("gift_box", 14 + 3 * party());
547
+ spawnEdge("popper", { count: 6 });
548
+ every(5, () => {
549
+ if (current === 1) spawnEdge("popper", { count: 3 + party() });
550
+ });
551
+ },
552
+ won: () => alive("gift_box") === 0,
553
+ },
554
+ {
555
+ name: "CATCH THE PUP",
556
+ seconds: 30,
557
+ start: () => {
558
+ spawnEdge("party_pup", { count: 1, side: "ahead", spread: 90 });
559
+ spawnEdge("wobbler", { count: 2 + Math.floor(party() / 2) });
560
+ // The pup's friends keep getting in the way.
561
+ every(5, () => {
562
+ if (current === 2) spawnEdge("fizz", { count: 2 + Math.floor(party() / 2) });
563
+ });
564
+ },
565
+ won: () => pupCaught,
566
+ },
567
+ {
568
+ name: "HOLD THE FLOOR",
569
+ seconds: 35,
570
+ lastsOut: true,
571
+ start: () => {
572
+ const tide = () => {
573
+ if (current !== 3) return;
574
+ const side = SIDES[Math.floor(random() * SIDES.length)] ?? "north";
575
+ spawnEdge("popper", { count: 18 + 4 * party(), side, spread: 80 });
576
+ spawnEdge("spritzer", { count: 2, side, spread: 60 });
577
+ };
578
+ tide();
579
+ every(9, tide);
580
+ },
581
+ won: () => false,
582
+ },
583
+ {
584
+ name: "BURST THE BIG BALLOON",
585
+ seconds: 60,
586
+ start: () => {
587
+ spawnEdge("big_balloon", { count: 1 });
588
+ spawnEdge("fizz", { count: 6 });
589
+ // Its fizz keep coming while it floats.
590
+ every(6, () => {
591
+ if (current === 4) spawnEdge("fizz", { count: 2 + Math.floor(party() / 2) });
592
+ });
593
+ },
594
+ won: () => balloonBurst,
595
+ },
596
+ ];
597
+ // The show's order: the first four shuffled on the sim's random, so every show plays differently;
598
+ // the Big Balloon is always the finale.
599
+ const order = ROUNDS.map((_, i) => i);
600
+ for (let i = order.length - 2; i > 0; i--) {
601
+ const j = Math.floor(random() * (i + 1));
602
+ [order[i], order[j]] = [order[j] ?? 0, order[i] ?? 0];
603
+ }
604
+ let step = -1;
605
+ let current = -1;
606
+ let endsAt = 0;
607
+ let wins = 0;
608
+ const sweep = () => {
609
+ for (const id of SWEEP) clear(id);
610
+ };
611
+ const next = () => {
612
+ step++;
613
+ if (step >= order.length) {
614
+ const score = wins + " OF " + order.length;
615
+ announce(wins >= HIT_AT ? "A HIT · " + score : "THE SHOW IS OVER · " + score, { tone: wins >= HIT_AT ? "reward" : "info" });
616
+ if (wins === order.length) emit("show.perfect");
617
+ after(3, () => end(wins >= HIT_AT ? "won" : "lost"));
618
+ return;
619
+ }
620
+ const r = ROUNDS[order[step] ?? 0] as Round;
621
+ current = order[step] ?? 0;
622
+ kills = 0;
623
+ downs = 0;
624
+ pupCaught = false;
625
+ balloonBurst = false;
626
+ announce("ROUND " + (step + 1) + " · " + r.name, { tone: "info" });
627
+ upcoming(r.name, r.seconds);
628
+ endsAt = tick() + r.seconds * 30;
629
+ r.start();
630
+ };
631
+ const finish = (won: boolean) => {
632
+ const r = ROUNDS[current];
633
+ current = -1;
634
+ if (won) {
635
+ wins++;
636
+ emit("round.won");
637
+ announce((r?.name ?? "ROUND") + " · WON", { tone: "reward" });
638
+ } else announce((r?.name ?? "ROUND") + " · MISSED", { tone: "danger" });
639
+ emit("wave.cleared");
640
+ // The round's clock gives way to the breather's (the last round has none: the show ends).
641
+ if (step + 1 < order.length) upcoming("NEXT ROUND", BREATHER);
642
+ after(1.5, sweep);
643
+ after(2, () => respawnDead());
644
+ after(BREATHER, next);
645
+ };
646
+ on("enemy.killed", (ids) => {
647
+ if (current >= 0) kills += Math.max(1, ids.length);
648
+ });
649
+ on("player.downed", () => {
650
+ if (current >= 0) downs++;
651
+ });
652
+ on("party_pup.killed", () => {
653
+ pupCaught = true;
654
+ });
655
+ on("miniboss.killed", () => {
656
+ balloonBurst = true;
657
+ });
658
+ onTick(() => {
659
+ if (current < 0 || tick() % 15 !== 0) return;
660
+ const r = ROUNDS[current] as Round;
661
+ if (r.won()) finish(true);
662
+ else if (tick() >= endsAt) finish(r.lastsOut === true && downs === 0);
663
+ });
664
+ // Doors open: a few poppers wander in while the first round is announced.
665
+ after(0.5, () => spawnEdge("popper", { count: 4 + party() }));
666
+ after(2, next);
667
+ });
668
+
669
+ // Keepers: pick one before your first level. New keepers unlock with achievements, never with coins.
670
+ keeper("host", { name: "Host", text: "Confetti all round and quick on the feet.", weapon: "confetti_pop", passive: "dance_shoes" });
671
+ keeper("juggler", { name: "Juggler", text: "Spinning plates and a wider spin.", model: "style.lowpoly.blocky_k", tint: "#7ad7ff", weapon: "spinning_plates", passive: "disco_ball", unlock: "first_hit" });
672
+ keeper("pie_chef", { name: "Pie Chef", text: "Pies from on high, and seconds.", model: "style.lowpoly.blocky_f", tint: "#ffb347", weapon: "pie_toss", passive: "second_helping", unlock: "balloon_popper" });
673
+
674
+ // Achievements (counted in a show; they unlock keepers and pay nothing).
675
+ achievement("first_hit", { title: "Crowd Pleaser", text: "Win three rounds in one show.", when: "round.won", n: 3 });
676
+ achievement("balloon_popper", { title: "Pop Goes", text: "Burst the Big Balloon.", when: "miniboss.killed" });
677
+ achievement("perfect_show", { title: "Standing Ovation", text: "Win every round of a show.", when: "show.perfect" });
678
+
679
+ // Daily goals: the HUD shows three a day. They pay nothing.
680
+ mission("rounds", { text: "Win 3 rounds", when: "round.won", n: 3 });
681
+ mission("pup", { text: "Catch the pup", when: "party_pup.killed" });
682
+ mission("revive", { text: "Help a friend up", when: "player.revived" });
683
+ mission("chest", { text: "Open 2 chests", when: "chest.collected", n: 2 });
684
+ mission("balloon", { text: "Burst the Big Balloon", when: "miniboss.killed" });
685
+
686
+ // End-screen titles: who stole the show at what.
687
+ title("life_of_party", { text: "Life of the Party", most: "kills" });
688
+ title("showstopper", { text: "Showstopper", most: "damage" });
689
+ title("good_friend", { text: "Good Friend", most: "revives" });
690
+ title("on_their_feet", { text: "On Their Feet", least: "downs" });
691
+ title("snacker", { text: "Snacker", most: "pickups" });
692
+
693
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per show each.
694
+ // Pay amounts copy EMBERWATCH's (5, 20, 50, 5) until ROMPHOUSE's own are set: PLACEHOLDER.
695
+ earn.rule("first_win", { when: "round.won", n: 1, pay: 5, oncePer: "run" });
696
+ earn.rule("crowd_favourite", { when: "round.won", n: 4, pay: 20, oncePer: "run" });
697
+ earn.rule("full_house", { when: "show.perfect", n: 1, pay: 50, oncePer: "run" });
698
+ earn.rule("confetti_storm", { when: "enemy.killed", n: 70, pay: 5, oncePer: "run" });
699
+ // Buy-in run (the mandatory sink loop): stake coins at the start of a show to earn above free play.
700
+ // Values are PLACEHOLDER.
701
+ earn.stake({ min: 20, max: 200, multiple: 2 });
702
+
703
+ // Store (the Kit), priced in coins (the default coin model). Prices copy EMBERWATCH's: PLACEHOLDER.
704
+ store.sku("revive", { coins: 50 });
705
+ store.sku("skin_disco", { coins: 300, grants: { cosmetic: 1 } });
706
+ // Losable gear: owned for good until lost in a staked show that makes less than loseBelow of its most earn.
707
+ store.sku("lucky_charm", { coins: 150, grants: { gear: "lucky_sock", loseBelow: 0.25 } });
708
+ ```
709
+
710
+ ## Validate and playtest
711
+
712
+ - Validate: the MCP tool `netforge_validate_game` builds `src/game.ts` and returns one actionable line per problem (an unknown asset id, a missing earn.stake, a bad id). The same checks throw `GameDefinitionError` when the module is imported.
713
+ - Playtest: the MCP tool `netforge_run_playtest`, or in code `engine.test.run({ ticks: 30 * 180, bots: 4, assert: [] })`. It reports kills, wave (1 plus the rounds played), maxLevel, earned by rule, deaths, runs won and lost, softlock and ms per tick. With `bot: "human"` a human-like bot plays and `report.fun` holds the fun metrics the gate scores.
714
+ - Template tests: test/harness.test.ts, test/host.test.ts, test/store.test.ts, run with `npx vitest run` in this package.
715
+ - Play locally: put `src/game.ts` and a `netnet.json` of `{"template":"party"}` in a folder, install `@netforge/kit-play` and `@netforge/engine`, and run `npx kit-play` (or the MCP tool `netforge_play_locally`). It opens http://127.0.0.1:4663 with play money.
716
+ - Docs: https://forge.netnet.capital/docs/templates/party
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # ROMPHOUSE: the party co-op template (NetNet game runtime)
1
+ # Net Forge party co-op template (ROMPHOUSE)
2
2
 
3
3
  > A party game for 2 to 8 friends in one big playroom: a show of five
4
4
  > quick rounds, each a different minigame with one goal for the whole
package/package.json CHANGED
@@ -1,12 +1,29 @@
1
1
  {
2
2
  "name": "@netforge/template-party",
3
- "version": "0.1.0",
4
- "description": "ROMPHOUSE: a party co-op template on the NetNet game runtime: 2 to 8 friends in one playroom, five quick minigame rounds a show. The game module, its browser client and its playtest bots.",
3
+ "version": "0.2.0",
4
+ "description": "Party game template for Net Forge: 2 to 8 friends in one playroom, five quick minigame rounds a show (ROMPHOUSE).",
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
+ "party-game"
18
+ ],
5
19
  "license": "MIT",
6
20
  "type": "module",
7
21
  "exports": {
8
22
  ".": "./src/manifest.ts",
9
- "./store": "./src/store.ts"
23
+ "./store": "./src/store.ts",
24
+ "./package.json": "./package.json",
25
+ "./llms.txt": "./llms.txt",
26
+ "./llms-full.txt": "./llms-full.txt"
10
27
  },
11
28
  "files": [
12
29
  "src",
@@ -15,26 +32,37 @@
15
32
  "llms.txt",
16
33
  "README.md",
17
34
  "NOTICE",
18
- "LICENSE"
35
+ "LICENSE",
36
+ "llms-full.txt",
37
+ "AGENTS.md",
38
+ "CLAUDE.md"
39
+ ],
40
+ "sideEffects": [
41
+ "./src/game.ts",
42
+ "./src/manifest.ts",
43
+ "./client/**"
19
44
  ],
20
45
  "publishConfig": {
21
46
  "access": "public"
22
47
  },
48
+ "homepage": "https://forge.netnet.capital/docs/templates/party",
49
+ "bugs": {
50
+ "url": "https://github.com/netnetdev/net-forge/issues"
51
+ },
23
52
  "repository": {
24
53
  "type": "git",
25
54
  "url": "git+https://github.com/netnetdev/net-forge.git",
26
55
  "directory": "packages/template-party"
27
56
  },
28
- "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-party#readme",
29
57
  "engines": {
30
58
  "node": ">=20"
31
59
  },
32
60
  "dependencies": {
33
61
  "nipplejs": "1.0.4",
34
62
  "three": "0.186.1",
35
- "@netforge/asset-library": "0.1.0",
36
- "@netforge/engine-sim": "0.1.0",
37
- "@netforge/engine": "0.1.0"
63
+ "@netforge/asset-library": "0.2.0",
64
+ "@netforge/engine-sim": "0.2.0",
65
+ "@netforge/engine": "0.2.0"
38
66
  },
39
67
  "devDependencies": {
40
68
  "@types/node": "22.20.1",
@@ -44,9 +72,6 @@
44
72
  "vite": "6.4.3",
45
73
  "vitest": "3.2.7"
46
74
  },
47
- "bugs": {
48
- "url": "https://github.com/netnetdev/net-forge/issues"
49
- },
50
75
  "scripts": {
51
76
  "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
52
77
  "typecheck": "tsc -p tsconfig.json",