benjivers/val Public Public: anyone can read this; the crew can change it.

Play from the terminal, and say what is real in the README

src/tty.ts takes a seat over the same socket the browser uses and draws the
grid, the scoreboard and the round in ANSI: walk, shoot, cast, plant, defuse
and buy without leaving the pty. It finds the app at VAL_URL, PORT or
FORGE_APP_URL, in that order.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 6782320 Browse files
2 files changed +223 −67
README.md +69 −13
@@ -1,23 +1,79 @@
11 # val
22
3 Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.
3 Five attackers with the spike, five defenders, abilities on cooldown. First to 13.
44
5 This is an app: the forge runs it. Production follows the default branch;
6 every open pull gets a preview at its own address with its own copy of the
7 data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what
8 makes "production data" and "preview data" two different things.
5 A top-down 5v5 played in a browser. One match is always running on the
6 server: open the page and you are given a seat in the round that is
7 happening, taken from a bot where it stands. When a match ends it shows the
8 result for a few seconds and starts a new one, so nobody ever arrives at a
9 frozen board.
10
11 ## What is real
12
13 Everything that decides a round is computed on the server and only the
14 server:
15
16 - **Movement, shooting and hit detection.** The client sends intent — a
17 direction, an angle, a held key — never a position. Damage is by hit
18 location (how close the ray passes the target) with falloff and armour:
19 a Vandal head shot kills through full armour at any range, a Phantom's
20 does not past fifteen metres.
21 - **Line of sight.** A snapshot only carries the enemies that viewer can
22 see. A smoke blocks that sight line, a flash blinds by the angle you were
23 facing, and a blinded player is sent no enemies at all. A patched client
24 learns nothing a plain one does not.
25 - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a
26 checkpoint at 3.5s — a defender killed past half way leaves the defuse
27 half done for the next one.
28 - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a
29 plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is
30 refused server-side.
31 - **Bots** fill every empty seat and use their kit: smokes on the way in,
32 flashes before the entry, a recon dart, a molly on the planted spike.
33
34 Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits
35 (charges last the round), a free signature on a cooldown, and an ultimate
36 paid for with ult points from kills, plants and the two orbs on the map.
37
38 Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
39
40 ## What is not
41
42 - One map, one match at a time, ten seats. There is no matchmaking, no
43 ranking and no account: your seat belongs to a session cookie.
44 - No overtime — first to 13 ends it at 12–12 too.
45 - Sight is a full circle rather than a view cone; top-down has no vertical,
46 so the hit location comes from how precisely the shot passes the target.
47 - Assists are counted as a field but never awarded.
48 - The live match lives in memory and is gone if the process restarts; only
49 finished match results are written to SQLite under `FORGE_APP_DATA`.
50
51 ## Playing
52
53 WASD move, the mouse aims, click fires, **R** reloads, **F** plants and
54 defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone
55 the left thumb walks and the right thumb aims and fires.
956
1057 ```sh
1158 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
1259 bun test # the check every pull runs
13 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
60 VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts # the terminal interface
1461 ```
1562
16 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
17 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is
18 the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on
19 a real pty over the same data.
63 The terminal plays the same match over the same socket: it draws the grid,
64 the scoreboard and the round, and takes a seat. The exact command that
65 reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it.
2066
21 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`
22 (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT`
23 when it starts the app. `forge.json` says how.
67 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`,
68 `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json`
69 says how.
70
71 ## Shape
72
73 `src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table ·
74 `src/economy.ts` credits · `src/abilities.ts` the agent kits ·
75 `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes
76 no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
77 live match and its sockets · `src/server.ts` the door · `static/val.js` the
78 browser. The rules are pure functions and the tests play whole matches
79 headlessly.
src/tty.ts +154 −54
@@ -1,74 +1,174 @@
11 /**
2 * val, in a terminal. The forge runs this on a pty when someone
3 * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the
4 * web page uses. j/k move, a adds a note, d deletes the one under the
5 * cursor, q leaves. Plain ANSI, no dependencies.
2 * val, in a terminal. The forge runs this on a pty when somebody ssh-es to
3 * the app; it takes a seat over the same socket the browser uses, so what a
4 * person can do on the page they can do here: walk, shoot, cast, plant,
5 * defuse and buy.
6 *
7 * The map is the same grid, one character a tile. Plain ANSI, no dependencies.
68 */
79
8 import { Database } from "bun:sqlite";
9 import { mkdirSync } from "node:fs";
10 import { join } from "node:path";
10 const base =
11 process.env.VAL_URL ??
12 (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ??
13 process.env.FORGE_APP_URL ??
14 "http://127.0.0.1:8080";
1115
12 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
13 mkdirSync(dataDir, { recursive: true });
14 const db = new Database(join(dataDir, "app.db"));
15 db.exec("PRAGMA journal_mode = WAL");
16 db.exec(
17 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
18 );
19 const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local");
20
21 type Note = { id: number; body: string; created_at: string };
22 const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all();
23
24 let cursor = 0;
25 let typing: string | undefined;
26 const out = (s: string): void => { process.stdout.write(s); };
2716 const esc = "\x1b[";
17 const out = (s: string): void => void process.stdout.write(s);
18
19 type Snapshot = {
20 phase: string;
21 clock: number;
22 round: number;
23 score: { red: number; blue: number };
24 attackers: string;
25 result: string | null;
26 me: Record<string, any>;
27 players: Record<string, any>[];
28 zones: { k: string; x: number; y: number; r: number }[];
29 spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null };
30 board: Record<string, any>[];
31 events: string[];
32 };
33
34 let map: { rows: string[]; tile: number } | null = null;
35 let snap: Snapshot | null = null;
36 let socket: WebSocket | null = null;
37 let status = "connecting";
38 let aim = -Math.PI / 2;
39 let move = { mx: 0, my: 0, until: 0 };
40 let firing = 0;
41 let using = 0;
42 let buying = false;
43
44 const send = (message: unknown): void => {
45 if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
46 };
47
48 async function connect(): Promise<void> {
49 try {
50 const page = await fetch(base + "/");
51 const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
52 map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number };
53 const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", {
54 headers: { cookie, origin: new URL(base).origin },
55 } as unknown as string[]);
56 socket = ws;
57 ws.onopen = () => (status = "live");
58 ws.onclose = () => {
59 status = "reconnecting";
60 socket = null;
61 setTimeout(connect, 2000);
62 };
63 ws.onmessage = (event) => {
64 snap = JSON.parse(String(event.data)) as Snapshot;
65 };
66 } catch (error) {
67 status = `cannot reach ${base}`;
68 setTimeout(connect, 3000);
69 }
70 }
71
72 const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`;
2873
2974 function draw(): void {
30 const rows = notes();
75 if (!map) {
76 out(`${esc}2J${esc}Hval — ${status}\r\n`);
77 return;
78 }
79 const rows = map.rows.map((row) => row.split("").map((c) => (c === "#" ? colour("▓", 90) : c === "A" || c === "B" ? colour(c, 33) : c === "a" || c === "d" ? colour("·", 90) : " ")));
80 const put = (x: number, y: number, ch: string): void => {
81 const col = Math.floor(x / map!.tile);
82 const row = Math.floor(y / map!.tile);
83 if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch;
84 };
85
3186 const lines: string[] = [];
32 lines.push(`${esc}1mval${esc}0m ${esc}2m${where}${esc}0m`);
87 if (snap) {
88 for (const zone of snap.zones) put(zone.x, zone.y, colour(zone.k === "smoke" ? "▒" : zone.k === "molly" ? "≈" : zone.k === "wall" ? "█" : "*", zone.k === "molly" ? 31 : 37));
89 if (snap.spike.x !== null && snap.spike.y !== null)
90 put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33));
91 for (const player of snap.players) {
92 if (!player.alive) continue;
93 const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31);
94 put(player.x, player.y, mark);
95 }
96 const me = snap.me;
97 const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase;
98 lines.push(
99 `${colour("val", 1)} ${colour("red " + snap.score.red, 31)} — ${colour(snap.score.blue + " blue", 36)} round ${snap.round} ${phase} ${Math.ceil(snap.clock)}s ${snap.attackers} attacking [${status}]`,
100 );
101 lines.push(
102 `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`,
103 );
104 } else lines.push(`val — ${status}`);
33105 lines.push("");
34 if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`);
35 rows.forEach((n, i) => {
36 const mark = i === cursor ? "▸" : " ";
37 const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body;
38 lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`);
39 });
106 for (const row of rows) lines.push(" " + row.join(""));
40107 lines.push("");
41 lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`);
42 out(`${esc}2J${esc}H` + lines.join("\r\n"));
108 if (snap) {
109 for (const side of ["atk", "def"] as const) {
110 const team = snap.board
111 .filter((p) => p.side === side)
112 .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`)
113 .join(" ");
114 lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team);
115 }
116 lines.push("");
117 for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90));
118 }
119 lines.push("");
120 lines.push(
121 buying
122 ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33)
123 : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90),
124 );
125 out(`${esc}H${esc}J` + lines.join("\r\n"));
43126 }
44127
128 const BUYS: Record<string, string> = {
129 "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator",
130 "8": "light", "9": "heavy",
131 };
132
45133 process.stdin.setRawMode?.(true);
46134 process.stdin.resume();
47135 process.stdin.setEncoding("utf8");
48 draw();
49136 process.stdin.on("data", (key: string) => {
50 if (typing !== undefined) {
51 if (key === "\r") {
52 const body = typing.trim();
53 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500));
54 typing = undefined;
55 cursor = 0;
56 } else if (key === "\x1b") typing = undefined;
57 else if (key === "\x7f") typing = typing.slice(0, -1);
58 else if (key >= " ") typing += key;
59 draw();
137 const now = Date.now();
138 if (key === "\x03" || (key === "q" && !buying)) {
139 out(`${esc}2J${esc}H`);
140 process.exit(0);
141 }
142 if (buying) {
143 if (BUYS[key]) send({ t: "buy", what: BUYS[key] });
144 else if (key === "c" || key === "q") send({ t: "buy", what: key });
145 else if (key === "b" || key === "\x1b") buying = false;
60146 return;
61147 }
62 if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); }
63 const count = notes().length;
64 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
65 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
66 if (key === "a") typing = "";
67 if (key === "d") {
68 const n = notes()[cursor];
69 if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id);
70 cursor = Math.min(cursor, Math.max(0, notes().length - 1));
71 }
72 draw();
148 if (key === "b") return void (buying = true);
149 if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 };
150 if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 };
151 if (key === "a") move = { mx: -1, my: move.my, until: now + 400 };
152 if (key === "d") move = { mx: 1, my: move.my, until: now + 400 };
153 if (key === "\x1b[D") aim -= Math.PI / 12;
154 if (key === "\x1b[C") aim += Math.PI / 12;
155 if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 };
156 if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 };
157 if (key === " ") firing = now + 220;
158 if (key === "f") using = now + 700;
159 if (key === "r") send({ t: "i", r: true });
160 if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key });
73161 });
162
163 // Twenty times a second, the same intent the browser sends.
164 setInterval(() => {
165 const now = Date.now();
166 if (now > move.until) move = { mx: 0, my: 0, until: 0 };
167 send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false });
168 }, 50);
169 setInterval(draw, 120);
74170 process.stdout.on("resize", draw);
171
172 out(`${esc}2J`);
173 draw();
174 void connect();
↑↓ navigate ↵ open esc close

Keyboard shortcuts