@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/AGENTS.md +63 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +74 -2
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +576 -0
- package/client/icons.ts +42 -0
- package/client/index.html +277 -0
- package/client/input.ts +172 -0
- package/client/library.ts +250 -0
- package/client/main.ts +607 -0
- package/client/names.ts +47 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stage.ts +266 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +716 -0
- package/llms.txt +375 -0
- package/package.json +82 -3
- package/src/game.ts +326 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
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
|