@netforge/template-arena 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NetNet Capital Management
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,34 @@
1
+ @netforge/template-arena (VOLTYARD)
2
+ Copyright (c) 2026 NetNet Capital Management
3
+
4
+ An original game. No names, characters, maps, models, sounds or code from
5
+ any other game or franchise.
6
+
7
+ Art and sound
8
+ - Models come from the per-style asset library
9
+ (@netforge/asset-library): CC0 1.0 models by Kenney (Mini
10
+ Characters, Blaster Kit, Space Kit, Car Kit), each pack listed with its
11
+ source URL and licence verification in that package's NOTICE and
12
+ manifest/packs.json. The files are fetched and built, never committed
13
+ here; the build copies only the models this game uses.
14
+ - Fallback shapes (client/library.ts) are generated from three.js
15
+ primitives; all textures are drawn on a canvas at load; all sounds are
16
+ synthesized with WebAudio oscillators (client/audio.ts).
17
+
18
+ Dependencies (unmodified, via npm)
19
+ - three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
20
+ - nipplejs 1.0.4 (https://github.com/yoannmoinet/nipplejs), MIT.
21
+ - @netforge/engine and @netforge/engine-sim (this repository);
22
+ see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
23
+ zod MIT).
24
+
25
+ Patterns (ideas only)
26
+ - The arena shooter genre's general mechanics (twin-stick aim, weapon
27
+ loadouts, respawns, frag limits, health drops, a scoreboard). Mechanics,
28
+ not expression; no level, weapon, character or text was copied from any
29
+ game.
30
+ - Lag compensation for hitscan as described in Valve's public Source
31
+ multiplayer networking write-up (ideas only; implemented in the Net Forge
32
+ room server and engine-sim's arena seam).
33
+ - Hit-stop, screen shake and damage numbers as described in common
34
+ game-feel talks and articles (e.g. "Juice it or lose it", GDC 2012).
package/README.md CHANGED
@@ -1,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # VOLTYARD (Template 2: arena shooter)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Wirejacks duel at night in a yard of humming transformers. Twin-stick: move
4
+ with one hand, aim with the other, hold fire. Swap between an arc rifle, a
5
+ scatter coil and a rail lance. Every kill drops a heal cell. Rounds last
6
+ three minutes or end at 25 kills; the top of the board takes the round.
7
+ Bots fill empty seats, so one player alone still gets a fight.
8
+
9
+ | Path | What |
10
+ |---|---|
11
+ | `src/game.ts` | The game, as the model writes it (declarations only). |
12
+ | `src/manifest.ts` | Loads and compiles it; `arenaAdapter()` for the room server and client. Room template `shooter`. |
13
+ | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): three round upgrades (40, 40, 60 coins) and two finishes (300, 500 coins), shown under the token's own symbol and logo; mock until the token is live. |
14
+ | `client/` | The browser client: three.js scene, HUD, twin-stick input (keys and mouse, gamepad, touch), game feel. |
15
+ | `test/` | Playtest bots through `engine.test.run`, and the store. |
16
+ | `llms.txt` | How to change the game (for agents). |
17
+
18
+ ## How it plays on the server
19
+
20
+ - Server-authoritative at 60 Hz. Your own wirejack is predicted (move, aim,
21
+ weapon, cooldowns); everyone else is drawn ~100 ms in the past.
22
+ - The rail lance is a hitscan resolved with lag compensation: the server
23
+ rewinds the other players to what you saw (capped at 200 ms). Rifle and
24
+ scatter shots are server projectiles.
25
+ - Seat bots (`players.bots`) join while at least one person is in the
26
+ room and leave as people join. Against a person they react late, aim
27
+ loose and fire in bursts by that person's record this round, sharpening
28
+ as the person wins. A bot level with or ahead of the best person eases
29
+ off (looser aim, slower reactions, and it would rather fight other bots),
30
+ but bots never stop shooting and never earn. Killing a bot scores and
31
+ shows in the feed but pays no earn rule. Only a person reaches the frag
32
+ limit.
33
+ - Spawns: out of every opponent's sight (physics raycasts) and 12 m clear,
34
+ with 2 s of protection on joining, respawning and each new round.
35
+ - `test/newcomer.test.ts` plays a deliberately weak new player alone with
36
+ the seat bots over 10 rounds: first kill within about 20 s, no deaths
37
+ within 3 s of spawning, top half in most rounds without winning every
38
+ one, no bot reaching the frag limit. It also reports what the newcomer
39
+ earns per round, which alone with bots is nothing.
40
+ - Earn: kills of people (3 and 12 in a round), a five-kill streak, and
41
+ topping the board with another person in the round. Bot kills and a board
42
+ of bots pay nothing, enforced by the engine's
43
+ `GameRunner` for every arena game. The room server is the referee; before graduation earn is credited
44
+ as points, after it the game's own coins under its own name.
45
+
46
+ ## Run it
47
+
48
+ This template runs on your own computer with play money through
49
+ `kit play` (`@netforge/kit-play`). From the repository root:
50
+
51
+ ```sh
52
+ pnpm install --frozen-lockfile && pnpm build
53
+ mkdir -p play-arena/src && cp packages/template-arena/src/game.ts play-arena/src/
54
+ echo '{"template":"arena"}' > play-arena/netnet.json
55
+ node packages/kit-play/dist/cli.js play-arena # opens http://127.0.0.1:4663
56
+ ```
57
+
58
+ Edit `play-arena/src/game.ts` and run it again to try a change. Real-token
59
+ earning, stakes and claims run only on the hosted Net Forge server.
60
+
61
+ Open the Invite link in another tab or on a phone on the same network to
62
+ play together (`?room=<id>`). Keys: WASD, mouse aim, click fire, 1 2 3 or Q
63
+ weapon, Space dash, Tab board. Touch: left stick moves, right stick aims and
64
+ fires, SWAP and DASH buttons. ` shows stats.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * VOLTYARD's sound cues on the engine's FeelAudio (FeelKit). Shots, downs
3
+ * and heals play real CC0 samples from the asset library's audio section
4
+ * (ARENA_SAMPLES, loaded by the engine's SoundPlayer with a music loop);
5
+ * the FeelKit's own cues (impact, bigKill, hurt) take the library's samples
6
+ * by name. Two cues stay synthesized on purpose so you can tell them apart
7
+ * without looking: the hit tick and the two-note kill confirm. Every cue
8
+ * keeps a synth recipe as the fallback when a sample cannot load. Others'
9
+ * cues ("-o") are played quieter. Starts on the first user gesture.
10
+ */
11
+ import type { FeelAudio } from "@netforge/engine/runtime";
12
+
13
+ /** Arena cue name -> library sound id (soundBank's `cues` overrides). */
14
+ export const ARENA_SAMPLES: Readonly<Record<string, string>> = {
15
+ zap: "audio.sfx.shoot",
16
+ "zap-o": "audio.sfx.shoot",
17
+ wide: "audio.sfx.shoot",
18
+ "wide-o": "audio.sfx.shoot",
19
+ down: "audio.sfx.downed",
20
+ "down-o": "audio.sfx.kill",
21
+ heal: "audio.sfx.revive",
22
+ };
23
+
24
+ export function defineArenaCues(a: FeelAudio): void {
25
+ a.define("zap", { wave: "square", f0: 1300, f1: 500, ms: 50, gain: 0.35 }, { minGapMs: 40, voices: 3 });
26
+ a.define("zap-o", { wave: "square", f0: 1300, f1: 500, ms: 50, gain: 0.35 }, { minGapMs: 40, voices: 3 });
27
+ a.define("wide", { wave: "square", f0: 260, f1: 60, ms: 140, gain: 0.6 }, { minGapMs: 60, voices: 2 });
28
+ a.define("wide-o", { wave: "square", f0: 260, f1: 60, ms: 140, gain: 0.6 }, { minGapMs: 60, voices: 2 });
29
+ a.define("beam", { wave: "sawtooth", f0: 1800, f1: 120, ms: 220, gain: 0.7 }, { minGapMs: 60, voices: 2 });
30
+ a.define("beam-o", { wave: "sawtooth", f0: 1800, f1: 120, ms: 220, gain: 0.7 }, { minGapMs: 60, voices: 2 });
31
+ a.define("hitmark", { wave: "triangle", f0: 2600, f1: 1900, ms: 40, gain: 0.55 }, { minGapMs: 35, voices: 2, pitchVar: 0.03 });
32
+ a.define("confirm", { wave: "triangle", f0: 660, f1: 990, ms: 120, gain: 0.8 }, { minGapMs: 80, voices: 2, pitchVar: 0 });
33
+ a.define("confirm2", { wave: "triangle", f0: 990, f1: 1980, ms: 200, gain: 0.8 }, { minGapMs: 80, voices: 2, pitchVar: 0 });
34
+ a.define("down-o", { wave: "triangle", f0: 520, f1: 260, ms: 160, gain: 0.3 }, { minGapMs: 120, voices: 2 });
35
+ a.define("down", { wave: "sawtooth", f0: 300, f1: 40, ms: 600, gain: 0.8 }, { minGapMs: 300, voices: 1 });
36
+ a.define("heal", { wave: "sine", f0: 440, f1: 880, ms: 180, gain: 0.6 }, { minGapMs: 150, voices: 1 });
37
+ }
38
+
39
+ /** Our kill: two rising notes. */
40
+ export function killConfirm(a: FeelAudio): void {
41
+ a.play("confirm");
42
+ setTimeout(() => a.play("confirm2"), 90);
43
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Library models the VOLTYARD scene draws on its own (template machinery,
3
+ * not game content). The build copies these with the models game.ts uses.
4
+ */
5
+ /** Seat bots: a different townsperson from the players' model. */
6
+ export const BOT_MODEL = "style.lowpoly.mini_male_e";
7
+ /** The blaster every wirejack holds. */
8
+ export const HELD_MODEL = "style.lowpoly.blaster_h";
9
+ /** Each transformer pillar: a generator machine. */
10
+ export const PILLAR_MODEL = "style.lowpoly.generator";
11
+ /** Wirejacks are drawn this tall, m. */
12
+ export const PLAYER_HEIGHT = 1.9;
13
+
14
+ /** [library id, height m, how many] parked outside the fence. */
15
+ export const YARD_DECOR: readonly (readonly [string, number, number])[] = [
16
+ ["style.lowpoly.truck_flat", 2, 3],
17
+ ["style.lowpoly.van", 2, 3],
18
+ ["style.lowpoly.barrels", 1.3, 6],
19
+ ["style.lowpoly.satellite_dish", 3.2, 2],
20
+ ];
21
+
22
+ export const SCENE_MODELS: readonly string[] = [BOT_MODEL, HELD_MODEL, PILLAR_MODEL, ...YARD_DECOR.map(([id]) => id)];
package/client/host.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Net Forge as host. Framed by Net Forge, the page URL carries
3
+ * the Kit flags and the host posts one message; played on its own, none of
4
+ * this applies and the game behaves exactly as before.
5
+ *
6
+ * ?host=netnet the host draws Invite and sign-in; the game hides its own
7
+ * ?coin=TICKER the coin the HUD names (2 to 6 capitals or digits)
8
+ * ?title=NAME the game's own name for the wordmark and the tab title
9
+ * ?player=guest no wallet is connected: play pays nothing, and the HUD
10
+ * says "not earning" instead of a balance
11
+ * ?player=play the token has not launched: coins are play money
12
+ *
13
+ * The player flag changes while the game runs (a wallet connects), so the
14
+ * host also posts { kind: "netnet-kit", type: "player", mode }. Only the
15
+ * parent window is listened to, and only when the URL says it is the host.
16
+ */
17
+
18
+ export type PlayerMode = "earning" | "guest" | "play";
19
+
20
+ export interface HostFlags {
21
+ /** Framed by Net Forge. */
22
+ readonly framed: boolean;
23
+ /** The coin's ticker, when the host names one. */
24
+ readonly coin: string | null;
25
+ /** The game's own name, when the host names one. */
26
+ readonly title: string | null;
27
+ /** What play pays the person at the screen. Standalone play is "earning" (the game's own kit decides). */
28
+ readonly player: PlayerMode;
29
+ }
30
+
31
+ const TICKER_RE = /^[A-Z0-9]{2,6}$/;
32
+
33
+ export function hostFlags(params: URLSearchParams): HostFlags {
34
+ const framed = params.get("host") === "netnet";
35
+ const coin = params.get("coin");
36
+ // eslint-disable-next-line no-control-regex
37
+ const title = (params.get("title") ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 40);
38
+ const player = params.get("player");
39
+ return {
40
+ framed,
41
+ coin: coin && TICKER_RE.test(coin) ? coin : null,
42
+ title: title || null,
43
+ player: framed && (player === "guest" || player === "play") ? player : "earning",
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The player mode the host named on the URL, or null when it named none (a
49
+ * connected player on a live game gets no flag, only the posted message) or
50
+ * the page is not framed by Net Forge.
51
+ */
52
+ export function namedPlayer(params: URLSearchParams): PlayerMode | null {
53
+ if (params.get("host") !== "netnet") return null;
54
+ const player = params.get("player");
55
+ return player === "earning" || player === "guest" || player === "play" ? player : null;
56
+ }
57
+
58
+ /**
59
+ * The store's note under its prices, from one source of truth. Once the host
60
+ * has named the player mode (on the URL or in a message), that mode decides,
61
+ * exactly as it does for the coin box: a live game never calls its coins play
62
+ * money. Standalone play, or a host that names no mode (the studio's drafts),
63
+ * keeps the room's own word (`welcome.earnLive`).
64
+ */
65
+ export function storeNote(hostMode: PlayerMode | null, earnLive: boolean, symbol: string): string {
66
+ if (hostMode === "play") return "Play money until launch";
67
+ if (hostMode === "guest") return `Connect a wallet to earn ${symbol}`;
68
+ if (hostMode === "earning") return "";
69
+ return earnLive ? "" : "Play money until launch";
70
+ }
71
+
72
+ /** A host message, checked: anything else is ignored. */
73
+ export function playerFrom(data: unknown): PlayerMode | null {
74
+ if (typeof data !== "object" || data === null) return null;
75
+ const m = data as Record<string, unknown>;
76
+ if (m["kind"] !== "netnet-kit" || m["type"] !== "player") return null;
77
+ const mode = m["mode"];
78
+ return mode === "earning" || mode === "guest" || mode === "play" ? mode : null;
79
+ }
80
+
81
+ /** Calls `cb` with each player mode the host posts. Framed pages only. */
82
+ export function onHostPlayer(flags: HostFlags, cb: (mode: PlayerMode) => void): void {
83
+ if (!flags.framed || window.parent === window) return;
84
+ window.addEventListener("message", (e: MessageEvent<unknown>) => {
85
+ if (e.source !== window.parent) return;
86
+ const mode = playerFrom(e.data);
87
+ if (mode) cb(mode);
88
+ });
89
+ }
90
+
91
+ /** Tells the host the game is up, so it can post the current player mode. */
92
+ export function postReady(flags: HostFlags): void {
93
+ if (!flags.framed || window.parent === window) return;
94
+ // The host's origin is not known to an opaque-origin frame; the message is one public flag.
95
+ window.parent.postMessage({ kind: "netnet-kit", type: "ready" }, "*");
96
+ }
package/client/hud.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The HUD: plain DOM over the canvas. Text updates are
3
+ * throttled; bars move every frame with transforms. Copy is labels, not
4
+ * sentences.
5
+ */
6
+ import { BOT_PLAYER_BASE, EntityFlag, maxHealthFor, selectedSlot, slotWeapon } from "@netforge/engine-sim";
7
+ import type { ResolvedSimConfig } from "@netforge/engine-sim";
8
+ import { F, PLAYER_UNITS } from "@netforge/engine/game";
9
+ import type { CompiledGame, RunInfo } from "@netforge/engine/game";
10
+ import type { SceneEntity } from "./scene.js";
11
+ import { RULE_NAMES, TITLE, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
12
+ import { css, playerColor } from "./palette.js";
13
+
14
+ const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
15
+ const e = document.getElementById(id);
16
+ if (!e) throw new Error(`#${id} missing`);
17
+ return e as T;
18
+ };
19
+
20
+ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
21
+
22
+ /** Seat bots go by first names (the full board still marks them as bots). */
23
+ export const BOT_NAMES = ["Mara", "Dex", "Juno", "Rook", "Vega", "Nico", "Tamsin", "Ozzy", "Lark", "Remy", "Sable", "Bram", "Ivy", "Cass", "Wren", "Kit"];
24
+
25
+ /** Short player labels: YOU, P2, or a seat bot's name. */
26
+ export function playerLabel(id: number, self: number): string {
27
+ if (id === self) return "YOU";
28
+ return id >= BOT_PLAYER_BASE ? (BOT_NAMES[(id - BOT_PLAYER_BASE) % BOT_NAMES.length] ?? "Bot").toUpperCase() : `P${id}`;
29
+ }
30
+
31
+ /** Off-screen opponents within this many metres get an edge arrow. */
32
+ const ARROW_RANGE = 45;
33
+
34
+ export interface HudCallbacks {
35
+ pickWeapon(slot: number): void;
36
+ invite(): void;
37
+ toggleStore(): void;
38
+ toggleSound(): boolean;
39
+ }
40
+
41
+ export class Hud {
42
+ private lastText = 0;
43
+ private readonly earnedThisRun = new Map<string, number>();
44
+ private toastTimer = 0;
45
+ private lastRun = 0;
46
+ private symbol = "";
47
+ /** What play pays here (client/host.ts): a guest in Net Forge earns nothing, and the HUD says so. */
48
+ private mode: "earning" | "guest" | "play" = "earning";
49
+ private slotKey = "";
50
+ private boardOpen = false;
51
+ private readonly cdTicks: number[];
52
+
53
+ constructor(
54
+ private readonly game: CompiledGame,
55
+ private readonly config: ResolvedSimConfig,
56
+ private readonly selfPlayer: number,
57
+ private readonly cb: HudCallbacks,
58
+ ) {
59
+ $("invite").addEventListener("click", () => cb.invite());
60
+ $("store-btn").addEventListener("click", () => cb.toggleStore());
61
+ $("store-close").addEventListener("click", () => cb.toggleStore());
62
+ // The label names the current state. Sound starts on, so the first click mutes and reads "Sound off".
63
+ $("sound").textContent = "Sound on";
64
+ $("sound").addEventListener("click", () => {
65
+ $("sound").textContent = cb.toggleSound() ? "Sound off" : "Sound on";
66
+ });
67
+ $("title").textContent = TITLE.toUpperCase();
68
+ this.cdTicks = config.content.weapons.map((w) => w.cooldownTicks);
69
+ // Weapon slots: one card per loadout weapon.
70
+ const slots = $("slots");
71
+ slots.innerHTML = "";
72
+ game.loadout.forEach((wi, s) => {
73
+ const id = game.weaponIds[wi] ?? "";
74
+ const card = document.createElement("button");
75
+ card.className = "slot";
76
+ card.id = `slot${s}`;
77
+ card.innerHTML = `<kbd>${s + 1}</kbd><b></b><span></span><i class="cd"><i></i></i>`;
78
+ (card.querySelector("b") as HTMLElement).textContent = WEAPON_NAMES[id]?.name ?? id;
79
+ (card.querySelector("span") as HTMLElement).textContent = WEAPON_NAMES[id]?.line ?? "";
80
+ card.addEventListener("click", () => cb.pickWeapon(s));
81
+ slots.append(card);
82
+ });
83
+ // Tab holds the full board open; the corner keeps only the top three and your own row.
84
+ addEventListener("keydown", (e) => {
85
+ if (e.code === "Tab") {
86
+ e.preventDefault();
87
+ this.boardOpen = true;
88
+ $("board-full").classList.add("open");
89
+ }
90
+ });
91
+ addEventListener("keyup", (e) => {
92
+ if (e.code === "Tab") {
93
+ this.boardOpen = false;
94
+ $("board-full").classList.remove("open");
95
+ }
96
+ });
97
+ }
98
+
99
+ /** Names the coin everywhere the HUD shows it: the token's own name, symbol and logo. */
100
+ coin(name: string, symbol: string, logo: string | null): void {
101
+ this.symbol = symbol;
102
+ const img = $<HTMLImageElement>("coin-logo");
103
+ if (logo) {
104
+ img.src = logo;
105
+ img.alt = name;
106
+ img.hidden = false;
107
+ } else img.hidden = true;
108
+ $("coin-name").textContent = name;
109
+ }
110
+
111
+ get coinSymbol(): string {
112
+ return this.symbol;
113
+ }
114
+
115
+ /** The wordmark: the game's own name (Net Forge names it), else the template's. */
116
+ title(name: string): void {
117
+ $("title").textContent = name.toUpperCase();
118
+ }
119
+
120
+ /**
121
+ * What play pays the person at the screen. A guest (no wallet in
122
+ * Net Forge) sees "not earning" where the balance was, and earn toasts say
123
+ * how to get paid; play money says so.
124
+ */
125
+ player(mode: "earning" | "guest" | "play"): void {
126
+ this.mode = mode;
127
+ const box = $("coin-box");
128
+ box.classList.toggle("guest", mode === "guest");
129
+ box.classList.toggle("play", mode === "play");
130
+ box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
131
+ this.lastText = 0;
132
+ }
133
+
134
+ /** The coin box's text: the balance, or "VOLT · not earning" for a guest (play money gets a caption in CSS). */
135
+ private balanceText(balance: number): string {
136
+ if (this.mode === "guest") return `${this.symbol} · not earning`;
137
+ return `${Math.floor(balance).toLocaleString("en-US")} ${this.symbol}`;
138
+ }
139
+
140
+ earned(rule: string, units: number): void {
141
+ this.earnedThisRun.set(rule, (this.earnedThisRun.get(rule) ?? 0) + units);
142
+ const pay = this.game.manifest.earnRules.get(rule)?.pay ?? 0;
143
+ const name = RULE_NAMES[rule] ?? rule;
144
+ this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay * units} ${this.symbol}` : `${name} +${pay * units} ${this.symbol}`);
145
+ }
146
+
147
+ toast(text: string): void {
148
+ const t = $("toast");
149
+ t.textContent = text;
150
+ t.classList.add("show");
151
+ clearTimeout(this.toastTimer);
152
+ this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2400);
153
+ }
154
+
155
+ /** Every frame. `self` is the predicted character; `players` every wirejack in view (others interpolated). */
156
+ update(
157
+ now: number,
158
+ self: SceneEntity | undefined,
159
+ players: readonly SceneEntity[],
160
+ run: RunInfo | null,
161
+ project: (x: number, y: number, z: number) => { x: number; y: number; onScreen: boolean },
162
+ balance: number,
163
+ ): void {
164
+ if (run && run.run !== this.lastRun) {
165
+ this.lastRun = run.run;
166
+ this.earnedThisRun.clear();
167
+ }
168
+ const text = now - this.lastText > 100;
169
+ if (self) {
170
+ const hp = self.values[F.health] ?? 0;
171
+ const max = maxHealthFor(this.config, self.values[F.stats] ?? 0);
172
+ $("hp-fill").style.transform = `scaleX(${Math.min(1, hp / Math.max(1, max))})`;
173
+ const dead = ((self.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0;
174
+ $("down").classList.toggle("show", dead && run?.phase === "playing");
175
+ // Weapon slots: the selected one lit, cooldowns as bars.
176
+ const weapons = self.values[F.weapons] ?? 0;
177
+ const wcd = self.values[F.wcd] ?? 0;
178
+ const slot = selectedSlot(self.values[F.choices] ?? 0);
179
+ for (let s = 0; s < this.game.loadout.length; s++) {
180
+ const card = document.getElementById(`slot${s}`);
181
+ if (!card) continue;
182
+ const wi = slotWeapon(weapons, s);
183
+ const full = this.cdTicks[wi] ?? 1;
184
+ const cd = (wcd >>> (s * 8)) & 0xff;
185
+ (card.querySelector(".cd > i") as HTMLElement | null)?.style.setProperty("transform", `scaleX(${Math.min(1, cd / full)})`);
186
+ }
187
+ const key = String(slot);
188
+ if (key !== this.slotKey) {
189
+ this.slotKey = key;
190
+ for (let s = 0; s < this.game.loadout.length; s++) document.getElementById(`slot${s}`)?.classList.toggle("on", s === slot);
191
+ }
192
+ if (text) {
193
+ $("hp-text").textContent = `${hp}`;
194
+ if (dead) $("down-sub").textContent = `${Math.max(1, Math.ceil((self.values[F.cooldown] ?? 0) / this.config.tickRate))}s`;
195
+ // Store upgrades this round.
196
+ const stats = self.values[F.stats] ?? 0;
197
+ const active = this.game.upgradeIds.filter((_, i) => ((stats >>> (i * 4)) & 0x0f) > 0).map((id) => UPGRADE_NAMES[id]?.name ?? id);
198
+ $("buffs").textContent = active.join(" ");
199
+ }
200
+ }
201
+ if (text) $("coin-balance").textContent = this.balanceText(balance);
202
+
203
+ // Name tags and health over every other wirejack on screen; an edge arrow for each one nearby but off screen.
204
+ const tags = $("tags");
205
+ const arrows = $("arrows");
206
+ const sx = (self?.values[F.px] ?? 0) / PLAYER_UNITS;
207
+ const sz = (self?.values[F.pz] ?? 0) / PLAYER_UNITS;
208
+ const w = innerWidth;
209
+ const h = innerHeight;
210
+ let ti = 0;
211
+ let ai = 0;
212
+ for (const p of players) {
213
+ if (self && p.owner === self.owner) continue;
214
+ if (((p.values[F.flags] ?? 0) & EntityFlag.Dead) !== 0) continue;
215
+ const px = (p.values[F.px] ?? 0) / PLAYER_UNITS;
216
+ const pz = (p.values[F.pz] ?? 0) / PLAYER_UNITS;
217
+ const s = project(px, 2.4, pz);
218
+ if (!s.onScreen) {
219
+ const dist = Math.hypot(px - sx, pz - sz);
220
+ if (!self || dist > ARROW_RANGE) continue;
221
+ // From the screen centre toward the opponent, pinned inside the HUD margins (the weapon cards sit at the bottom).
222
+ const dx = s.x - w / 2;
223
+ const dy = s.y - h / 2;
224
+ const hx = w / 2 - 34;
225
+ const hy = dy < 0 ? h / 2 - 80 : h / 2 - 118;
226
+ const k = Math.min(hx / Math.max(1e-3, Math.abs(dx)), hy / Math.max(1e-3, Math.abs(dy)));
227
+ let a = arrows.children[ai] as HTMLElement | undefined;
228
+ if (!a) {
229
+ a = document.createElement("i");
230
+ arrows.append(a);
231
+ }
232
+ ai++;
233
+ const near = 1 - Math.min(1, Math.max(0, (dist - 12) / (ARROW_RANGE - 12)));
234
+ a.style.setProperty("--c", css(playerColor(p.owner)));
235
+ a.style.opacity = (0.45 + 0.55 * near).toFixed(2);
236
+ a.style.transform = `translate(${(w / 2 + dx * k).toFixed(1)}px, ${(h / 2 + dy * k).toFixed(1)}px) rotate(${Math.atan2(dy, dx).toFixed(3)}rad) scale(${(0.8 + 0.5 * near).toFixed(2)})`;
237
+ continue;
238
+ }
239
+ let t = tags.children[ti] as HTMLElement | undefined;
240
+ if (!t) {
241
+ t = document.createElement("div");
242
+ t.className = "tag";
243
+ t.innerHTML = "<span></span><i><i></i></i>";
244
+ tags.append(t);
245
+ }
246
+ ti++;
247
+ const max = maxHealthFor(this.config, p.values[F.stats] ?? 0);
248
+ t.style.transform = `translate(${s.x}px, ${s.y}px) translate(-50%, -100%)`;
249
+ t.style.setProperty("--c", css(playerColor(p.owner)));
250
+ const label = playerLabel(p.owner, this.selfPlayer);
251
+ const span = t.firstElementChild as HTMLElement;
252
+ if (span.textContent !== label) span.textContent = label;
253
+ ((t.lastElementChild as HTMLElement).firstElementChild as HTMLElement).style.transform = `scaleX(${Math.min(1, (p.values[F.health] ?? 0) / Math.max(1, max))})`;
254
+ }
255
+ while (tags.children.length > ti) tags.lastElementChild?.remove();
256
+ while (arrows.children.length > ai) arrows.lastElementChild?.remove();
257
+
258
+ if (text) {
259
+ this.lastText = now;
260
+ if (run) {
261
+ const left = run.round?.left ?? 0;
262
+ $("timer").textContent = mmss(left);
263
+ $("timer").classList.toggle("low", left <= 15 && run.phase === "playing");
264
+ $("goal").textContent = run.round?.frags ? `FIRST TO ${run.round.frags}` : "MOST KILLS";
265
+ this.board(run);
266
+ this.feed(run);
267
+ this.end(run);
268
+ }
269
+ }
270
+ }
271
+
272
+ /** Kills first; on a tie a person ranks above a seat bot (bots never take the round). */
273
+ private standings(run: RunInfo) {
274
+ return [...run.players].sort((a, b) => b.kills - a.kills || Number(a.bot) - Number(b.bot) || a.deaths - b.deaths || a.id - b.id);
275
+ }
276
+
277
+ private board(run: RunInfo): void {
278
+ const all = this.standings(run);
279
+ const rank = all.findIndex((p) => p.id === this.selfPlayer);
280
+ const row = (p: RunInfo["players"][number], i: number, full: boolean) => {
281
+ const me = p.id === this.selfPlayer ? ' class="me"' : "";
282
+ const tag = full && p.bot ? '<i class="bot">BOT</i>' : "";
283
+ return `<li${me} style="--c:${css(playerColor(p.id))}"><em>${i + 1}</em><span class="dot"></span>${playerLabel(p.id, this.selfPlayer)}${tag}<b>${p.kills}</b>${full ? `<small>${p.deaths}</small>` : ""}</li>`;
284
+ };
285
+ // The corner: the top three, and your own row when you are lower.
286
+ const mini = all
287
+ .map((p, i) => ({ p, i }))
288
+ .filter(({ i }) => i < 3 || i === rank)
289
+ .map(({ p, i }) => row(p, i, false))
290
+ .join("");
291
+ const list = $("board");
292
+ if (list.innerHTML !== mini) list.innerHTML = mini;
293
+ if (!this.boardOpen) return;
294
+ const html = `<li class="head"><em></em><span></span>PLAYER<b>K</b><small>D</small></li>${all.map((p, i) => row(p, i, true)).join("")}`;
295
+ const full = $("board-full");
296
+ if (full.innerHTML !== html) full.innerHTML = html;
297
+ }
298
+
299
+ private feed(run: RunInfo): void {
300
+ const items = run.feed
301
+ .filter((f) => f.ago < 6)
302
+ .slice(-4)
303
+ .map((f) => `<li><span style="color:${css(playerColor(f.by))}">${playerLabel(f.by, this.selfPlayer)}</span><i>&#x2715;</i><span style="color:${css(playerColor(f.victim))}">${playerLabel(f.victim, this.selfPlayer)}</span></li>`);
304
+ const html = items.join("");
305
+ const list = $("feed");
306
+ if (list.innerHTML !== html) list.innerHTML = html;
307
+ }
308
+
309
+ private end(run: RunInfo): void {
310
+ const el = $("end");
311
+ const show = run.phase !== "playing";
312
+ el.classList.toggle("show", show);
313
+ if (!show) return;
314
+ // The round goes to the best person in it; a seat bot never takes it.
315
+ const top = this.standings(run).find((p) => !p.bot);
316
+ $("end-title").textContent = top && top.kills > 0 ? (top.id === this.selfPlayer ? "TOP OF THE YARD" : `${playerLabel(top.id, this.selfPlayer)} TAKES IT`) : "ROUND OVER";
317
+ $("end-board").innerHTML = this.standings(run)
318
+ .slice(0, 8)
319
+ .map((p, i) => `<li${p.id === this.selfPlayer ? ' class="me"' : ""}><em>${i + 1}</em>${playerLabel(p.id, this.selfPlayer)}<b>${p.kills}</b><small>${p.deaths}</small></li>`)
320
+ .join("");
321
+ const earned = [...this.earnedThisRun].map(([r, u]) => `<li>${RULE_NAMES[r] ?? r}<b>+${(this.game.manifest.earnRules.get(r)?.pay ?? 0) * u} ${this.symbol}</b></li>`);
322
+ $("end-earned").innerHTML =
323
+ (earned.length > 0 ? earned.join("") : "<li>No earn this round</li>") + (this.mode === "guest" && earned.length > 0 ? `<li class="guest">Not paid: connect a wallet to earn ${this.symbol}</li>` : "");
324
+ $("end-next").textContent = run.nextRunIn !== null ? `Next round ${run.nextRunIn}s` : "";
325
+ }
326
+
327
+ stats(text: string): void {
328
+ $("stats").textContent = text;
329
+ }
330
+ }