@netforge/template-racer 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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The buy-in run in the store panel (earn.stake, the mandatory sink loop).
3
+ * The player picks an amount inside the game's terms and stakes it: the Kit
4
+ * spends it under the reason "stake" (earned coins first, then the wallet),
5
+ * then the room is told. The room fixes the stake from its own ledger when
6
+ * the next run starts (or this one, if it only just began) and says so.
7
+ */
8
+ import type { PotMessage, StakeMessage, StakeTermsView } from "@netforge/engine/net";
9
+ import type { KitStoreBridge } from "../src/store.js";
10
+
11
+ export interface StakeConn {
12
+ readonly welcome: { readonly stake?: StakeTermsView };
13
+ stake(amount?: number): void;
14
+ onStake(cb: (m: StakeMessage) => void): void;
15
+ }
16
+
17
+ const fmt = (n: number) => (Math.round(n * 100) / 100).toLocaleString("en-US");
18
+
19
+ /** Wires #stake-amount, #stake-go and #stake-line; hides #stake-row when the game takes no stakes. */
20
+ export function wireStake(conn: StakeConn, kit: KitStoreBridge, toast: (t: string) => void): void {
21
+ const row = document.getElementById("stake-row");
22
+ const input = document.getElementById("stake-amount") as HTMLInputElement | null;
23
+ const go = document.getElementById("stake-go") as HTMLButtonElement | null;
24
+ const line = document.getElementById("stake-line");
25
+ const terms = conn.welcome.stake;
26
+ if (!row || !input || !go || !line) return;
27
+ if (!terms) {
28
+ row.style.display = "none";
29
+ return;
30
+ }
31
+ const sym = kit.coins.symbol();
32
+ input.min = String(terms.min);
33
+ input.max = String(terms.max);
34
+ input.step = "any";
35
+ input.value = String(terms.min);
36
+ const idle = () => {
37
+ const a = Number(input.value) || terms.min;
38
+ line.textContent = `Up to ${fmt(a * terms.multiple)} ${sym} back`;
39
+ };
40
+ idle();
41
+ input.addEventListener("input", idle);
42
+ go.addEventListener("click", async () => {
43
+ const a = Number(input.value);
44
+ if (!(a >= terms.min && a <= terms.max)) return toast(`Stake ${fmt(terms.min)} to ${fmt(terms.max)} ${sym}`);
45
+ go.disabled = true;
46
+ try {
47
+ await kit.coins.spend(a, "stake");
48
+ conn.stake(a);
49
+ } catch {
50
+ toast(`Not enough ${sym}`);
51
+ } finally {
52
+ go.disabled = false;
53
+ }
54
+ });
55
+ conn.onStake((m) => {
56
+ if (m.state === "live") line.textContent = `Staked ${fmt(m.stake)}, up to ${fmt(m.ceiling ?? 0)} ${sym} this run`;
57
+ else if (m.state === "waiting") line.textContent = m.stake > 0 ? `Next run: ${fmt(m.stake)} ${sym}` : "Stake waits for tomorrow's cap";
58
+ else if (m.state === "closed") {
59
+ toast(`Stake ${fmt(m.stake)} ${sym} ${fmt(m.paid ?? 0)} back${m.lost && m.lost.length > 0 ? ` · gear lost` : ""}`);
60
+ idle();
61
+ } else toast(m.reason === "sign in to stake" ? "Sign in to stake" : "Stake not taken");
62
+ });
63
+ }
64
+
65
+ /** One losable gear SKU (store.sku grants { gear, loseBelow }): a buy button and its loss line. Hides #gear-row without one. */
66
+ export function wireGear(gear: { id: string; name: string; coins: number; loseBelow: number } | null, kit: KitStoreBridge, toast: (t: string) => void): void {
67
+ const row = document.getElementById("gear-row");
68
+ const name = document.getElementById("gear-name");
69
+ const line = document.getElementById("gear-line");
70
+ const buy = document.getElementById("gear-buy") as HTMLButtonElement | null;
71
+ if (!row || !name || !line || !buy) return;
72
+ if (!gear) {
73
+ row.style.display = "none";
74
+ return;
75
+ }
76
+ const sym = kit.coins.symbol();
77
+ name.textContent = gear.name;
78
+ line.textContent = `Staked runs only. Lost under ${Math.round(gear.loseBelow * 100)}% of a run's earn`;
79
+ const show = () => {
80
+ buy.textContent = kit.store.owns(gear.id) ? "Owned" : `${gear.coins} ${sym}`;
81
+ buy.disabled = kit.store.owns(gear.id);
82
+ };
83
+ show();
84
+ buy.addEventListener("click", async () => {
85
+ try {
86
+ await kit.store.buy(gear.id, { pay: "coins" });
87
+ } catch {
88
+ toast(`Not enough ${sym}`);
89
+ }
90
+ show();
91
+ });
92
+ }
93
+
94
+ /** The game's first gear SKU from its manifest, with its label. */
95
+ export function gearOf(skus: ReadonlyMap<string, { coins?: number; grants?: unknown }>, names: Readonly<Record<string, string>>): { id: string; name: string; coins: number; loseBelow: number } | null {
96
+ for (const [id, s] of skus) {
97
+ const g = s.grants as { gear?: string; loseBelow?: number } | undefined;
98
+ if (g?.gear && typeof g.loseBelow === "number" && s.coins !== undefined) return { id, name: names[id] ?? id, coins: s.coins, loseBelow: g.loseBelow };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ export interface PotConn {
104
+ readonly welcome: { readonly pot?: { readonly entry: number; readonly recycle: number } };
105
+ pot(): void;
106
+ onPot(cb: (m: PotMessage) => void): void;
107
+ }
108
+
109
+ /** The round's PvP pot: #pot-go pays the entry and tells the room; #pot-line shows the pot. Hides #pot-row without one. */
110
+ export function wirePot(conn: PotConn, kit: KitStoreBridge, toast: (t: string) => void): void {
111
+ const row = document.getElementById("pot-row");
112
+ const go = document.getElementById("pot-go") as HTMLButtonElement | null;
113
+ const line = document.getElementById("pot-line");
114
+ const pot = conn.welcome.pot;
115
+ if (!row || !go || !line) return;
116
+ if (!pot) {
117
+ row.style.display = "none";
118
+ return;
119
+ }
120
+ const sym = kit.coins.symbol();
121
+ const idle = `${fmt(pot.entry)} ${sym} in. The round splits it`;
122
+ line.textContent = idle;
123
+ go.textContent = `${fmt(pot.entry)} ${sym}`;
124
+ go.addEventListener("click", async () => {
125
+ go.disabled = true;
126
+ try {
127
+ await kit.coins.spend(pot.entry, "pot");
128
+ conn.pot();
129
+ } catch {
130
+ toast(`Not enough ${sym}`);
131
+ } finally {
132
+ go.disabled = false;
133
+ }
134
+ });
135
+ conn.onPot((m) => {
136
+ if (m.state === "in") line.textContent = `In. Pot ${fmt(m.pot ?? m.entry)} ${sym}`;
137
+ else if (m.state === "waiting") line.textContent = "In the next round";
138
+ else if (m.state === "closed") {
139
+ toast(`Pot ${fmt(m.pot ?? 0)} ${sym} ${fmt(m.paid ?? 0)} to you`);
140
+ line.textContent = idle;
141
+ } else toast(m.reason === "sign in to enter" ? "Sign in to enter" : "Not entered");
142
+ });
143
+ }
package/llms-full.txt ADDED
@@ -0,0 +1,236 @@
1
+ # Net Forge racer template (TORQUEBAY)
2
+
3
+ > Kart races for up to 8 players on harbour circuits, with seat bots filling
4
+ > the grid. A round is one race of a few laps; the next round runs another
5
+ > circuit. You change the game by editing src/game.ts only. The template
6
+ > already has the kart physics (throttle, brakes, steering, grip, drift
7
+ > mini-turbos in three tiers, boost pads, coins that raise top speed, grass,
8
+ > barriers, kart bumps), laps through ordered checkpoints, the race clock and
9
+ > places, bots that drive the racing line, netcode with client prediction at
10
+ > 60 Hz, a streamed track (only the road near you is built), rendering in
11
+ > every art style, keyboard, gamepad and touch controls, earn and the store.
12
+ > Do not write renderers, netcode, physics or bot AI.
13
+
14
+ ## Files
15
+
16
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
17
+ game(), upgrade(), system(), earn.rule(), earn.stake(), store.sku().
18
+ - src/names.json: the title and every label players read.
19
+ - Everything else is template machinery.
20
+
21
+ ## Rules the build enforces
22
+
23
+ - game({ template: "racer", style: "lowpoly", tracks: [...] }) with 1 to 8 tracks.
24
+ - No weapon(), director(), keeper() or enemy prefabs.
25
+ - A track has 4 to 64 control points at least 6 m apart and is at least 200 m round.
26
+ - Asset ids look like "style.lowpoly.kart_a", never URLs or paths, and must be in the asset library.
27
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it.
28
+ - Ids are lower_snake_case. Every game declares at least one earn.rule and one earn.stake (the buy-in run).
29
+ - Upgrades raise one of: speed (top speed), haste (drift charge rate), might
30
+ (boost length), armor (grass and wall losses), magnet (coin reach, m).
31
+ - 4 players plus seat bots must all finish a race, and an earn rule must
32
+ fire, in a 4-minute playtest.
33
+
34
+ ## Units
35
+
36
+ Seconds, metres, metres per second. The ground plane: x and z in metres.
37
+ Tick rate 60 Hz. Default top speed 26 m/s; a kart is about 1.8 m long.
38
+
39
+ ## The loop
40
+
41
+ Everyone lines up on a staggered grid behind the line. A 3 s countdown, then
42
+ the race: W/Up accelerate, S/Down brake, A/D steer, hold Space or Shift
43
+ while steering through a bend to drift (sparks turn blue, orange, then
44
+ purple as the mini-turbo charges; release for a 0.5, 0.9 or 1.4 s boost).
45
+ Boost pads boost; each coin held adds 1% top speed, up to 10. Grass slows,
46
+ barriers bounce. Laps count through four quarter checkpoints in order. The
47
+ round closes 25 s after the first person finishes (or at round.seconds);
48
+ the results show, then the next circuit.
49
+
50
+ ## Building a track
51
+
52
+ tracks: [{
53
+ name: "Harbour Loop",
54
+ laps: 3,
55
+ width: 14, // asphalt, m (8 to 30)
56
+ points: [[0, 0], [90, 0], [150, 15], ...], // the closed centreline in driving order; a smooth curve runs through them
57
+ boosts: [[0.12, 0], [0.47, -0.4]], // [lap fraction 0..1, lateral -1 (left) .. 1 (right)]
58
+ coins: [[0.04, 0.5], [0.046, 0.5]], // same coordinates; each kart collects its own (at most 62)
59
+ verge: 4, // optional grass before the barrier, m
60
+ }]
61
+ Rules of thumb: 500 to 900 m a lap at 14 m wide runs 25 to 40 s a lap; keep
62
+ neighbouring points 20 m or more apart for smooth bends; a hairpin needs
63
+ about 25 m of radius; put boosts on the exits of bends and coin rows on the
64
+ racing line or slightly off it (a choice). Two to four laps a race.
65
+
66
+ ## Common changes
67
+
68
+ ### A new circuit
69
+ Add a track to tracks. Circuits come one a round (the room draws one per round).
70
+
71
+ ### Tune the handling
72
+ kart: { topSpeed: 30, turn: 2.6, grip: 10, driftGrip: 1.5 } (topSpeed 10-45,
73
+ accel 8-80, turn 1-4, grip 3-20, driftGrip 0.5-6, grass 0.2-1, boostSpeed 1-1.8).
74
+
75
+ ### Round length and bots
76
+ round: { seconds: 240 } (60 to 600). players.bots: 6 fills the grid up to 6
77
+ with seat bots (0 turns them off). Bots drive the racing line, shaded by
78
+ skill, never earn, and a win over a field of bots pays nothing extra.
79
+
80
+ ### Add an earn rule
81
+ earn.rule("lap_leader", { when: "lap.completed", n: 2, pay: 3, oncePer: "run" });
82
+ Events: player.finished (you finished the race), race.won (first, paid only
83
+ with another person in the round), race.podium (top three, same),
84
+ lap.completed, coin.collected, drift.boosted (a mini-turbo fired),
85
+ drift.ultra (a full-charge one), kart.bumped (a hard wall hit),
86
+ round.finished, run.started (unpaid), and anything you emit(). Each rule
87
+ pays once per race per player, in coins; the room server is the referee.
88
+
89
+ ### Track something with a system
90
+ system("clean_line", ({ on, emit }) => {
91
+ const scrapes = new Map<number, number>();
92
+ on("kart.bumped", (ids) => { for (const id of ids) scrapes.set(id, (scrapes.get(id) ?? 0) + 1); });
93
+ on("player.finished", (ids) => { const clean = ids.filter((id) => !scrapes.get(id)); if (clean.length) emit("race.clean", clean); });
94
+ on("run.started", (ids) => { for (const id of ids) scrapes.delete(id); });
95
+ });
96
+ Systems also have every, after, onTick, players(), random(), announce(text),
97
+ upcoming(label, seconds) and end(). spawn and enemies are not part of this template.
98
+
99
+ ### The buy-in run (required)
100
+ earn.stake({ min: 10, max: 100, multiple: 2 }); one top-level line. "stake" is reserved.
101
+
102
+ ### Losable gear (optional)
103
+ store.sku("bumper_kit", { coins: 120, grants: { gear: "bumpers", loseBelow: 0.25 } });
104
+ Owned in the profile, coins only; one level of its upgrade in every staked
105
+ race; lost when that race makes less than loseBelow of its most earn.
106
+
107
+ ### Coins: earn and the store
108
+ store.sku("turbo", { coins: 40 }); // one level of the "turbo" upgrade for the race
109
+ store.sku("skin_flame", { coins: 300, grants: { cosmetic: 1 } }); // a paint job, owned for good
110
+ Keep the five SKUs the client is wired to (turbo, tuned_engine, soft_tyres,
111
+ skin_flame, skin_chrome) at their prices, with the paints' grants (flame 1,
112
+ chrome 2). PvP pots are not part of this template.
113
+
114
+ ### Change the karts (the asset library)
115
+ players: { prefab: "style.lowpoly.race_car", ... } (any vehicle: kart_a..e,
116
+ race_car, race_car_future, buggy-like speeder_a..c). Seat bots drive
117
+ kart_b..e; the pixel style draws its own kart sprites.
118
+
119
+ ## Test
120
+
121
+ engine.test.run({ ticks: 60 * 240, bots: 4, assert: [...] }) runs races on
122
+ the server sim with 4 playtest drivers plus seat bots and reports rounds
123
+ (wave), runs.won (races every playtest driver finished), scorers (drivers
124
+ that finished at least once), deaths (hard wall hits), earned by rule,
125
+ softlock and ms per tick.
126
+
127
+ ## Worked example: src/game.ts
128
+
129
+ The whole game as the template ships it. Every change starts from this file.
130
+
131
+ ```ts
132
+ /**
133
+ * TORQUEBAY: the racer template (play style "racer").
134
+ * Up to eight karts race laps around harbour circuits: hold the throttle,
135
+ * drift through the bends to charge a mini-turbo (release for the boost),
136
+ * hit the boost pads, collect coins (every coin you hold adds 1% top speed,
137
+ * up to ten) and keep off the grass. A round is one race; the next round runs
138
+ * another circuit. Seat bots fill the grid so a race always has a field.
139
+ *
140
+ * This file is the whole game as the model writes it: declarations only,
141
+ * against the typed API in @netforge/engine. Everything else (the kart
142
+ * physics, netcode, renderer, track streaming, bots, referee) is the
143
+ * template's machinery.
144
+ *
145
+ * Track coordinates are metres on the ground (x right, z down the screen's
146
+ * map). points is the closed centreline in driving order; boosts and coins
147
+ * are [lap fraction 0..1, lateral -1..1 of the half width].
148
+ */
149
+ import { earn, game, store, system, upgrade } from "@netforge/engine";
150
+
151
+ /** Coins in a row: n coins from lap fraction f, `gap` of a lap apart, at one lateral. */
152
+ const coinRow = (f: number, lateral: number, n: number, gap = 0.006): [number, number][] => Array.from({ length: n }, (_, i) => [f + i * gap, lateral]);
153
+
154
+ export default game({
155
+ template: "racer",
156
+ style: "lowpoly",
157
+ tickRate: 60,
158
+ places: { harbour: { chunks: "places/harbour", spawn: [0, 0, 0] } },
159
+ players: { prefab: "style.lowpoly.kart_a", max: 8, bots: 6 },
160
+ round: { seconds: 240 },
161
+ tracks: [
162
+ {
163
+ name: "Harbour Loop",
164
+ laps: 3,
165
+ width: 14,
166
+ points: [[0, 0], [90, 0], [150, 15], [175, 65], [150, 120], [90, 135], [50, 175], [-20, 190], [-85, 160], [-110, 100], [-90, 35]],
167
+ boosts: [[0.12, 0], [0.47, -0.4], [0.78, 0.4]],
168
+ coins: [...coinRow(0.04, 0.5, 5), ...coinRow(0.3, -0.5, 5), ...coinRow(0.6, 0, 5), ...coinRow(0.88, -0.3, 5)],
169
+ },
170
+ {
171
+ name: "Crane Yard",
172
+ laps: 3,
173
+ width: 13,
174
+ points: [[0, 0], [70, -10], [120, 10], [130, 60], [95, 85], [60, 70], [30, 95], [45, 140], [5, 175], [-55, 160], [-75, 105], [-50, 60], [-75, 20]],
175
+ boosts: [[0.08, 0], [0.38, 0.3], [0.7, -0.3]],
176
+ coins: [...coinRow(0.15, 0, 5), ...coinRow(0.45, 0.4, 5), ...coinRow(0.62, -0.4, 5), ...coinRow(0.85, 0, 5)],
177
+ },
178
+ {
179
+ name: "Lighthouse Run",
180
+ laps: 2,
181
+ width: 15,
182
+ points: [[0, 0], [120, -15], [200, 10], [235, 70], [210, 140], [140, 170], [80, 150], [40, 190], [-30, 210], [-95, 175], [-115, 105], [-80, 40]],
183
+ boosts: [[0.05, 0], [0.25, 0.35], [0.5, -0.35], [0.8, 0]],
184
+ coins: [...coinRow(0.1, -0.4, 6), ...coinRow(0.35, 0.4, 6), ...coinRow(0.66, 0, 6), ...coinRow(0.9, 0.3, 6)],
185
+ },
186
+ ],
187
+ });
188
+
189
+ // Round upgrades sold in the store (one level each, for the race). Racer upgrades raise speed
190
+ // (top speed), haste (drift charge), might (boost length), armor (grass and wall losses) or magnet (coin reach).
191
+ upgrade("turbo", { stat: "might", step: 0.25, maxLevel: 1 });
192
+ upgrade("tuned_engine", { stat: "speed", step: 0.05, maxLevel: 1 });
193
+ upgrade("soft_tyres", { stat: "haste", step: 0.3, maxLevel: 1 });
194
+ upgrade("bumpers", { stat: "armor", step: 0.3, maxLevel: 1 });
195
+
196
+ // A clean race: finish without scraping a wall hard.
197
+ system("clean_line", ({ on, emit }) => {
198
+ const scrapes = new Map<number, number>();
199
+ on("kart.bumped", (ids) => {
200
+ for (const id of ids) scrapes.set(id, (scrapes.get(id) ?? 0) + 1);
201
+ });
202
+ on("player.finished", (ids) => {
203
+ const clean = ids.filter((id) => (scrapes.get(id) ?? 0) === 0);
204
+ if (clean.length > 0) emit("race.clean", clean);
205
+ });
206
+ on("run.started", (ids) => {
207
+ for (const id of ids) scrapes.delete(id);
208
+ });
209
+ });
210
+
211
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per race each.
212
+ // Seat bots never earn, and winning a field of bots pays nothing extra. Values are PLACEHOLDER.
213
+ earn.rule("hot_lap", { when: "drift.boosted", n: 3, pay: 4, oncePer: "run" });
214
+ earn.rule("chequered", { when: "player.finished", n: 1, pay: 10, oncePer: "run" });
215
+ earn.rule("clean_line", { when: "race.clean", n: 1, pay: 12, oncePer: "run" });
216
+ earn.rule("pole_to_flag", { when: "race.won", n: 1, pay: 25, oncePer: "run" });
217
+ // Buy-in run (the mandatory sink loop). PLACEHOLDER.
218
+ earn.stake({ min: 10, max: 100, multiple: 2 });
219
+
220
+ // Store (the Kit), priced in coins: round upgrades and two paint jobs owned for good.
221
+ store.sku("turbo", { coins: 40 });
222
+ store.sku("tuned_engine", { coins: 40 });
223
+ store.sku("soft_tyres", { coins: 60 });
224
+ store.sku("skin_flame", { coins: 300, grants: { cosmetic: 1 } });
225
+ store.sku("skin_chrome", { coins: 500, grants: { cosmetic: 2 } });
226
+ // Losable gear: bumpers in every staked race, lost when that race makes less than loseBelow of its most earn. PLACEHOLDER.
227
+ store.sku("bumper_kit", { coins: 120, grants: { gear: "bumpers", loseBelow: 0.25 } });
228
+ ```
229
+
230
+ ## Validate and playtest
231
+
232
+ - 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.
233
+ - Playtest: the MCP tool `netforge_run_playtest`, or in code `engine.test.run({ ticks: 60 * 240, bots: 4, assert: [] })`. It reports rounds (wave), runs.won (races every playtest driver finished), scorers, deaths (hard wall hits), earned by rule, softlock and ms per tick.
234
+ - Template tests: test/harness.test.ts, test/host.test.ts, test/race.test.ts, test/store.test.ts, run with `npx vitest run` in this package.
235
+ - Play locally: put `src/game.ts` and a `netnet.json` of `{"template":"racer"}` 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.
236
+ - Docs: https://forge.netnet.capital/docs/templates/racer
package/llms.txt ADDED
@@ -0,0 +1,125 @@
1
+ # Net Forge racer template (TORQUEBAY)
2
+
3
+ > Kart races for up to 8 players on harbour circuits, with seat bots filling
4
+ > the grid. A round is one race of a few laps; the next round runs another
5
+ > circuit. You change the game by editing src/game.ts only. The template
6
+ > already has the kart physics (throttle, brakes, steering, grip, drift
7
+ > mini-turbos in three tiers, boost pads, coins that raise top speed, grass,
8
+ > barriers, kart bumps), laps through ordered checkpoints, the race clock and
9
+ > places, bots that drive the racing line, netcode with client prediction at
10
+ > 60 Hz, a streamed track (only the road near you is built), rendering in
11
+ > every art style, keyboard, gamepad and touch controls, earn and the store.
12
+ > Do not write renderers, netcode, physics or bot AI.
13
+
14
+ ## Files
15
+
16
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
17
+ game(), upgrade(), system(), earn.rule(), earn.stake(), store.sku().
18
+ - src/names.json: the title and every label players read.
19
+ - Everything else is template machinery.
20
+
21
+ ## Rules the build enforces
22
+
23
+ - game({ template: "racer", style: "lowpoly", tracks: [...] }) with 1 to 8 tracks.
24
+ - No weapon(), director(), keeper() or enemy prefabs.
25
+ - A track has 4 to 64 control points at least 6 m apart and is at least 200 m round.
26
+ - Asset ids look like "style.lowpoly.kart_a", never URLs or paths, and must be in the asset library.
27
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it.
28
+ - Ids are lower_snake_case. Every game declares at least one earn.rule and one earn.stake (the buy-in run).
29
+ - Upgrades raise one of: speed (top speed), haste (drift charge rate), might
30
+ (boost length), armor (grass and wall losses), magnet (coin reach, m).
31
+ - 4 players plus seat bots must all finish a race, and an earn rule must
32
+ fire, in a 4-minute playtest.
33
+
34
+ ## Units
35
+
36
+ Seconds, metres, metres per second. The ground plane: x and z in metres.
37
+ Tick rate 60 Hz. Default top speed 26 m/s; a kart is about 1.8 m long.
38
+
39
+ ## The loop
40
+
41
+ Everyone lines up on a staggered grid behind the line. A 3 s countdown, then
42
+ the race: W/Up accelerate, S/Down brake, A/D steer, hold Space or Shift
43
+ while steering through a bend to drift (sparks turn blue, orange, then
44
+ purple as the mini-turbo charges; release for a 0.5, 0.9 or 1.4 s boost).
45
+ Boost pads boost; each coin held adds 1% top speed, up to 10. Grass slows,
46
+ barriers bounce. Laps count through four quarter checkpoints in order. The
47
+ round closes 25 s after the first person finishes (or at round.seconds);
48
+ the results show, then the next circuit.
49
+
50
+ ## Building a track
51
+
52
+ tracks: [{
53
+ name: "Harbour Loop",
54
+ laps: 3,
55
+ width: 14, // asphalt, m (8 to 30)
56
+ points: [[0, 0], [90, 0], [150, 15], ...], // the closed centreline in driving order; a smooth curve runs through them
57
+ boosts: [[0.12, 0], [0.47, -0.4]], // [lap fraction 0..1, lateral -1 (left) .. 1 (right)]
58
+ coins: [[0.04, 0.5], [0.046, 0.5]], // same coordinates; each kart collects its own (at most 62)
59
+ verge: 4, // optional grass before the barrier, m
60
+ }]
61
+ Rules of thumb: 500 to 900 m a lap at 14 m wide runs 25 to 40 s a lap; keep
62
+ neighbouring points 20 m or more apart for smooth bends; a hairpin needs
63
+ about 25 m of radius; put boosts on the exits of bends and coin rows on the
64
+ racing line or slightly off it (a choice). Two to four laps a race.
65
+
66
+ ## Common changes
67
+
68
+ ### A new circuit
69
+ Add a track to tracks. Circuits come one a round (the room draws one per round).
70
+
71
+ ### Tune the handling
72
+ kart: { topSpeed: 30, turn: 2.6, grip: 10, driftGrip: 1.5 } (topSpeed 10-45,
73
+ accel 8-80, turn 1-4, grip 3-20, driftGrip 0.5-6, grass 0.2-1, boostSpeed 1-1.8).
74
+
75
+ ### Round length and bots
76
+ round: { seconds: 240 } (60 to 600). players.bots: 6 fills the grid up to 6
77
+ with seat bots (0 turns them off). Bots drive the racing line, shaded by
78
+ skill, never earn, and a win over a field of bots pays nothing extra.
79
+
80
+ ### Add an earn rule
81
+ earn.rule("lap_leader", { when: "lap.completed", n: 2, pay: 3, oncePer: "run" });
82
+ Events: player.finished (you finished the race), race.won (first, paid only
83
+ with another person in the round), race.podium (top three, same),
84
+ lap.completed, coin.collected, drift.boosted (a mini-turbo fired),
85
+ drift.ultra (a full-charge one), kart.bumped (a hard wall hit),
86
+ round.finished, run.started (unpaid), and anything you emit(). Each rule
87
+ pays once per race per player, in coins; the room server is the referee.
88
+
89
+ ### Track something with a system
90
+ system("clean_line", ({ on, emit }) => {
91
+ const scrapes = new Map<number, number>();
92
+ on("kart.bumped", (ids) => { for (const id of ids) scrapes.set(id, (scrapes.get(id) ?? 0) + 1); });
93
+ on("player.finished", (ids) => { const clean = ids.filter((id) => !scrapes.get(id)); if (clean.length) emit("race.clean", clean); });
94
+ on("run.started", (ids) => { for (const id of ids) scrapes.delete(id); });
95
+ });
96
+ Systems also have every, after, onTick, players(), random(), announce(text),
97
+ upcoming(label, seconds) and end(). spawn and enemies are not part of this template.
98
+
99
+ ### The buy-in run (required)
100
+ earn.stake({ min: 10, max: 100, multiple: 2 }); one top-level line. "stake" is reserved.
101
+
102
+ ### Losable gear (optional)
103
+ store.sku("bumper_kit", { coins: 120, grants: { gear: "bumpers", loseBelow: 0.25 } });
104
+ Owned in the profile, coins only; one level of its upgrade in every staked
105
+ race; lost when that race makes less than loseBelow of its most earn.
106
+
107
+ ### Coins: earn and the store
108
+ store.sku("turbo", { coins: 40 }); // one level of the "turbo" upgrade for the race
109
+ store.sku("skin_flame", { coins: 300, grants: { cosmetic: 1 } }); // a paint job, owned for good
110
+ Keep the five SKUs the client is wired to (turbo, tuned_engine, soft_tyres,
111
+ skin_flame, skin_chrome) at their prices, with the paints' grants (flame 1,
112
+ chrome 2). PvP pots are not part of this template.
113
+
114
+ ### Change the karts (the asset library)
115
+ players: { prefab: "style.lowpoly.race_car", ... } (any vehicle: kart_a..e,
116
+ race_car, race_car_future, buggy-like speeder_a..c). Seat bots drive
117
+ kart_b..e; the pixel style draws its own kart sprites.
118
+
119
+ ## Test
120
+
121
+ engine.test.run({ ticks: 60 * 240, bots: 4, assert: [...] }) runs races on
122
+ the server sim with 4 playtest drivers plus seat bots and reports rounds
123
+ (wave), runs.won (races every playtest driver finished), scorers (drivers
124
+ that finished at least once), deaths (hard wall hits), earned by rule,
125
+ softlock and ms per tick.
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "@netforge/template-racer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Kart racer game template for Net Forge: harbour circuits with drift mini-turbos, boost pads, coins and laps at 60 Hz, with seat-filling bots (TORQUEBAY).",
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
+ "kart-racer",
18
+ "racing",
19
+ "physics"
20
+ ],
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "exports": {
24
+ ".": "./src/manifest.ts",
25
+ "./store": "./src/store.ts",
26
+ "./package.json": "./package.json",
27
+ "./llms.txt": "./llms.txt",
28
+ "./llms-full.txt": "./llms-full.txt"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "client",
33
+ "!client/vite.config.ts",
34
+ "llms.txt",
35
+ "README.md",
36
+ "NOTICE",
37
+ "LICENSE",
38
+ "llms-full.txt",
39
+ "AGENTS.md",
40
+ "CLAUDE.md"
41
+ ],
42
+ "sideEffects": [
43
+ "./src/game.ts",
44
+ "./src/manifest.ts",
45
+ "./client/**"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "homepage": "https://forge.netnet.capital/docs/templates/racer",
51
+ "bugs": {
52
+ "url": "https://github.com/netnetdev/net-forge/issues"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/netnetdev/net-forge.git",
57
+ "directory": "packages/template-racer"
58
+ },
59
+ "engines": {
60
+ "node": ">=20"
61
+ },
62
+ "dependencies": {
63
+ "three": "0.186.1",
64
+ "@netforge/asset-library": "0.2.0",
65
+ "@netforge/engine-sim": "0.2.0",
66
+ "@netforge/engine": "0.2.0"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "22.20.1",
70
+ "@types/three": "0.186.0",
71
+ "tsx": "4.23.0",
72
+ "typescript": "5.8.3",
73
+ "vite": "6.4.3",
74
+ "vitest": "3.2.7"
75
+ },
76
+ "scripts": {
77
+ "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
78
+ "typecheck": "tsc -p tsconfig.json",
79
+ "test": "vitest run",
80
+ "dev": "vite --config client/vite.config.ts",
81
+ "preview": "vite preview --config client/vite.config.ts"
82
+ }
6
83
  }