@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.
- package/AGENTS.md +65 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +33 -0
- package/README.md +74 -2
- package/client/audio.ts +26 -0
- package/client/decor.ts +27 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +257 -0
- package/client/index.html +189 -0
- package/client/input.ts +105 -0
- package/client/main.ts +464 -0
- package/client/names.ts +42 -0
- package/client/scene.ts +646 -0
- package/client/stake.ts +143 -0
- package/llms-full.txt +236 -0
- package/llms.txt +125 -0
- package/package.json +80 -3
- package/src/game.ts +96 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +109 -0
package/client/stake.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|