@netforge/template-party 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/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