@korial29/tiny-fps 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.
@@ -0,0 +1,28 @@
1
+ import type { Edge, Level, PlayerState } from '../shared/types';
2
+ type Vec3 = [number, number, number];
3
+ /**
4
+ * Column-major view-projection matrix: camera at eye looking along (-sin yaw cos pitch, sin pitch,
5
+ * -cos yaw cos pitch), y up, 70° vertical field of view.
6
+ */
7
+ export declare const viewProj: (eye: Vec3, yaw: number, pitch: number, aspect: number) => Float32Array<ArrayBuffer>;
8
+ /** A world point on screen, in [0, 1] from the top left; undefined behind the camera. */
9
+ export declare const project: (m: Float32Array, x: number, y: number, z: number) => [number, number] | undefined;
10
+ /**
11
+ * Appends the 36 vertices of a box centred on (cx, cy, cz) with half sizes (hx, hy, hz), turned
12
+ * by yaw about its own vertical axis (local forward, -z, ends up along forward(yaw)).
13
+ */
14
+ export declare const pushBox: (out: number[], [cx, cy, cz]: Vec3, [hx, hy, hz]: Vec3, yaw: number, color: number[]) => void;
15
+ /** Vertical spans [y0, y1] an edge draws on its own side: the wall, or the steps of a portal. */
16
+ export declare const spans: ({ map }: Level, { front, back }: Edge) => [number, number][];
17
+ /** Floors and ceilings as fans, then every edge's walls as quads, in the vertex format above. */
18
+ export declare const levelVerts: (lvl: Level) => number[];
19
+ export interface Renderer {
20
+ /** Draws one frame: the map and the others as seen from eye. colors maps player id to CSS. */
21
+ frame(eye: Vec3, yaw: number, pitch: number, others: PlayerState[], colors: ReadonlyMap<number, string>): void;
22
+ /** A world point on screen as of the last frame, in [0, 1] from the top left. */
23
+ project(x: number, y: number, z: number): [number, number] | undefined;
24
+ destroy(): void;
25
+ }
26
+ /** Throws when the browser has no WebGL2. */
27
+ export declare const createRenderer: (canvas: HTMLCanvasElement, lvl: Level) => Renderer;
28
+ export {};
@@ -0,0 +1,4 @@
1
+ export declare const SIZE = 128;
2
+ export declare const LAYERS: number;
3
+ /** All layers, RGBA, layer after layer, rows top to bottom: the upload of a texture array. */
4
+ export declare const textures: () => Uint8ClampedArray;
@@ -0,0 +1,14 @@
1
+ import type { Rules, SocketLike } from '../shared/types';
2
+ /**
3
+ * Same-browser multiplayer: tabs of one origin play together over a
4
+ * BroadcastChannel. One tab hosts the Room (elected with navigator.locks);
5
+ * when it closes, another tab takes over with a fresh Room (score back to 0-0)
6
+ * and every socket replays its join, so the client only sees a new roster.
7
+ *
8
+ * Without navigator.locks (old browsers, or a non-secure context such as plain
9
+ * http on a LAN address) every tab hosts its own Room and plays alone.
10
+ *
11
+ * `rules` are the match rules of the Room this tab creates when it hosts (missing ones fall
12
+ * back to RULES). Only the host's count: pass the same ones from every tab.
13
+ */
14
+ export declare const localSocket: (room: string, rules?: Partial<Rules>) => SocketLike;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { Server } from 'node:http';
2
+ import type { Rules } from '../shared/types';
3
+ export interface ServeOptions {
4
+ /** Listen on this port (ignored when server is given). */
5
+ port?: number;
6
+ /** Attach to an existing HTTP server instead. */
7
+ server?: Server;
8
+ /** Match rules for every room; missing ones fall back to RULES. */
9
+ rules?: Partial<Rules>;
10
+ }
11
+ /**
12
+ * Starts the WebSocket server: one Room per URL path. Returns an idempotent stop
13
+ * function that closes every socket, room, the WebSocketServer, and the HTTP
14
+ * server only when serve created it (`{ port }`).
15
+ */
16
+ export declare const serve: (opts: ServeOptions) => (() => Promise<void>);
@@ -0,0 +1,33 @@
1
+ import type { Rules } from './types';
2
+ /** Physics steps per second: one client input per step, the host steps a player per input. */
3
+ export declare const TICK_HZ = 60;
4
+ /** A snapshot goes out every SNAP_EVERY host ticks: 60 / 2 = 30 Hz. */
5
+ export declare const SNAP_EVERY = 2;
6
+ /**
7
+ * Render delay of the other players, interpolated between snapshots, in ms: about three snapshot
8
+ * intervals, so one lost or late snapshot does not freeze them. The own player is predicted.
9
+ */
10
+ export declare const INTERP_DELAY = 100;
11
+ /** Player cylinder. */
12
+ export declare const PLAYER_R = 0.4;
13
+ export declare const PLAYER_H = 1.8;
14
+ /** Camera height above the feet. */
15
+ export declare const EYE = 1.6;
16
+ /** Metres per second. */
17
+ export declare const RUN_SPEED = 6;
18
+ export declare const JUMP_SPEED = 7;
19
+ /** Metres per second squared. */
20
+ export declare const GRAVITY = 20;
21
+ /** Pitch is clamped to ±MAX_PITCH radians, just short of straight up or down. */
22
+ export declare const MAX_PITCH = 1.55;
23
+ /** A floor at most this much higher is walked onto; a higher one needs a jump. */
24
+ export declare const STEP_H = 0.5;
25
+ /**
26
+ * Host-side speed-hack guard: each player earns one input per host tick and may bank up to
27
+ * INPUT_BURST of them; inputs beyond the bank are dropped (and not acked).
28
+ */
29
+ export declare const INPUT_BURST = 10;
30
+ export declare const MAX_PLAYERS = 16;
31
+ /** Longest player name, in characters (code points). */
32
+ export declare const MAX_NAME = 16;
33
+ export declare const RULES: Rules;
@@ -0,0 +1,13 @@
1
+ import type { Level, MapDef, Sector } from './types';
2
+ /** Joins every edge a→b with the sector that has b→a, if any: that edge is a portal. */
3
+ export declare const compile: (map: MapDef) => Level;
4
+ /** Whether (x, z) is inside the sector, its edges included. */
5
+ export declare const inside: ({ pts }: Sector, x: number, z: number) => boolean;
6
+ /** Index of the first sector holding (x, z), -1 outside the map. */
7
+ export declare const sectorAt: ({ map }: Level, x: number, z: number) => number;
8
+ /**
9
+ * Whether a wall, a step or a ceiling hides b from a (both inside the map). Between two edge
10
+ * crossings the segment stays in one sector, and each crossing is checked against the opening of
11
+ * its portal, so floors and ceilings need no test of their own.
12
+ */
13
+ export declare const blocked: ({ map, edges }: Level, [ax, ay, az]: [number, number, number], [bx, by, bz]: [number, number, number]) => boolean;
@@ -0,0 +1,6 @@
1
+ import type { MapDef } from './types';
2
+ /**
3
+ * The first sector map, a test bed for heights: a start room, a low corridor, a tall hall around a
4
+ * lava pit, stairs up to a balcony. Each spot is reachable on foot; the pit is a 1 m drop.
5
+ */
6
+ export declare const HALL: MapDef;
@@ -0,0 +1,17 @@
1
+ import type { Input, Level, PlayerState } from './types';
2
+ export declare const TAU: number;
3
+ /** An angle in [0, 2π). */
4
+ export declare const norm: (a: number) => number;
5
+ /** Unit vector the player faces at yaw, on the floor plane: yaw 0 looks toward -z. */
6
+ export declare const forward: (yaw: number) => [x: number, z: number];
7
+ /**
8
+ * Applies one input for 1 / TICK_HZ s, in place: yaw and pitch from the input (pitch clamped to
9
+ * MAX_PITCH), RUN_SPEED along the held keys (diagonals not faster), a jump from the ground,
10
+ * GRAVITY, then collisions of the cylinder (PLAYER_R, PLAYER_H) with the sectors of the level:
11
+ * it slides along shut edges (see shut and push), stands on the highest floor under the circle
12
+ * (a step up to STEP_H is climbed at once, a higher one needs a jump, a ledge is a fall), and
13
+ * bumps its head on the lowest ceiling.
14
+ */
15
+ export declare const move: (p: PlayerState, i: Input, lvl: Level) => void;
16
+ /** A player standing on the floor at spawn point n of the map (modulo the count). */
17
+ export declare const spawn: (id: number, lvl: Level, n: number) => PlayerState;
@@ -0,0 +1,30 @@
1
+ import type { Input, PlayerInfo, Snapshot } from './types';
2
+ export type ClientText = {
3
+ t: 'join';
4
+ name: string;
5
+ };
6
+ export type ServerText = {
7
+ t: 'roster';
8
+ you: number;
9
+ players: PlayerInfo[];
10
+ } | {
11
+ t: 'full';
12
+ };
13
+ export declare const MSG_INPUT = 1;
14
+ export declare const MSG_SNAPSHOT = 2;
15
+ export declare const INPUT_BYTES = 8;
16
+ export declare const SNAP_HEAD = 4;
17
+ export declare const SNAP_PLAYER = 24;
18
+ /**
19
+ * The input as the host will read it after encodeInput and decodeInput: yaw and pitch rounded to
20
+ * the wire. The client predicts with this, so both sides move with the same values.
21
+ */
22
+ export declare const onWire: (i: Input) => Input;
23
+ export declare const encodeInput: (i: Input) => ArrayBuffer;
24
+ /** Null on anything that is not a well-formed input frame. */
25
+ export declare const decodeInput: (buf: ArrayBuffer) => Input | null;
26
+ export declare const encodeSnapshot: (s: Snapshot) => ArrayBuffer;
27
+ /** Null on anything that is not a well-formed snapshot frame. */
28
+ export declare const decodeSnapshot: (buf: ArrayBuffer) => Snapshot | null;
29
+ /** Returns null on anything that is not a known text message. */
30
+ export declare const parseText: <T extends ClientText | ServerText>(raw: string) => T | null;
@@ -0,0 +1,45 @@
1
+ import type { Conn, Every, Rules } from './types';
2
+ /**
3
+ * One game, transport-agnostic. The Node server and the local host tab both drive a Room: they
4
+ * feed it connections and frames, the room answers through Conn.send.
5
+ *
6
+ * - A peer joins with { t: 'join', name }: unique name (`Bob 2`), unique colour, a spawn point not
7
+ * taken by another player; 'full' beyond rules.maxPlayers. Everyone gets a fresh 'roster' on
8
+ * every join and leave.
9
+ * - Each input frame moves its player at once with move(); the INPUT_BURST bank drops the excess.
10
+ * - Every SNAP_EVERY ticks of TICK_HZ, one snapshot with every player goes to every peer.
11
+ */
12
+ export declare class Room {
13
+ /** Every connected peer; null until it joins. */
14
+ private peers;
15
+ private ticks;
16
+ private halt;
17
+ private rules;
18
+ /** Round-robin cursor over the spawn points, used once they are all taken. */
19
+ private rr;
20
+ /** Missing rules fall back to RULES. */
21
+ constructor(rules?: Partial<Rules>);
22
+ /** Number of joined players. */
23
+ get size(): number;
24
+ /** A peer opened. Nothing is sent until it joins. */
25
+ connect(conn: Conn): void;
26
+ /** A text (JSON) or binary frame from a peer. */
27
+ receive(conn: Conn, data: string | ArrayBuffer): void;
28
+ /** A peer left: its player goes, the roster goes out again. */
29
+ disconnect(conn: Conn): void;
30
+ /** One host tick. Public so tests can drive the room without a timer. */
31
+ tick(): void;
32
+ /**
33
+ * Drives tick() with a timer. An accumulator on performance.now() keeps the
34
+ * simulation at TICK_HZ whatever the timer jitter; after a stall at most
35
+ * MAX_CATCH_UP ticks are replayed and the rest of the backlog is dropped.
36
+ * `every` replaces setInterval, e.g. with a Worker-backed timer that
37
+ * background tabs do not throttle.
38
+ */
39
+ start(every?: Every): void;
40
+ stop(): void;
41
+ private players;
42
+ private join;
43
+ /** Each joined peer gets the full roster with its own id. */
44
+ private roster;
45
+ }
@@ -0,0 +1,131 @@
1
+ export interface PlayerInfo {
2
+ id: number;
3
+ name: string;
4
+ /** CSS colour, unique within the room. */
5
+ color: string;
6
+ }
7
+ /** Bits of Input.keys. Keys are read by KeyboardEvent.code, so WASD is ZQSD on AZERTY. */
8
+ export declare const KEY: {
9
+ readonly FORWARD: 1;
10
+ readonly BACK: 2;
11
+ readonly LEFT: 4;
12
+ readonly RIGHT: 8;
13
+ readonly JUMP: 16;
14
+ };
15
+ /** One client step: what the player held during 1 / TICK_HZ s, and where they looked. */
16
+ export interface Input {
17
+ /** Client step counter, u16, wraps. The host acks the last one it applied. */
18
+ seq: number;
19
+ /** KEY bits. */
20
+ keys: number;
21
+ /** Radians, [0, 2π). */
22
+ yaw: number;
23
+ /** Radians, [-MAX_PITCH, MAX_PITCH]. */
24
+ pitch: number;
25
+ }
26
+ export interface PlayerState {
27
+ id: number;
28
+ /** Feet position. */
29
+ x: number;
30
+ y: number;
31
+ z: number;
32
+ /** Vertical speed, m/s. */
33
+ vy: number;
34
+ yaw: number;
35
+ pitch: number;
36
+ /** Standing on a floor: a jump is allowed. */
37
+ ground: boolean;
38
+ }
39
+ /** Procedural textures, generated by the client. A sector names one per surface. */
40
+ export declare const TEX: {
41
+ readonly BRICK: 0;
42
+ readonly TILE: 1;
43
+ readonly LAVA: 2;
44
+ readonly STONE: 3;
45
+ };
46
+ /**
47
+ * A room, Doom style: a convex polygon on the floor plane with a flat floor and a flat ceiling.
48
+ * Two sectors are joined where one has the edge a→b and the other b→a with the very same
49
+ * coordinates (a portal); every other edge is a solid wall. Split an edge with collinear points
50
+ * when several sectors meet it (no T-junctions).
51
+ */
52
+ export interface Sector {
53
+ /**
54
+ * Corners as (x, z), counter-clockwise: the signed area Σ (x_i z_{i+1} - x_{i+1} z_i) is positive.
55
+ * Collinear points are allowed.
56
+ */
57
+ pts: [x: number, z: number][];
58
+ /** Heights in metres, ceil > floor. */
59
+ floor: number;
60
+ ceil: number;
61
+ /** TEX ids of the floor, the walls (and steps) on its edges, and the ceiling. */
62
+ ft: number;
63
+ wt: number;
64
+ ct: number;
65
+ /** Light level multiplied into the shading, 0 to 1. Absent: 1. */
66
+ light?: number;
67
+ }
68
+ export interface MapDef {
69
+ sectors: Sector[];
70
+ /** Spawn points: feet x, z (inside a sector, standing on its floor) and the yaw to face. */
71
+ spawns: [x: number, z: number, yaw: number][];
72
+ }
73
+ /** One directed edge of a sector, see compile in level.ts. */
74
+ export interface Edge {
75
+ ax: number;
76
+ az: number;
77
+ bx: number;
78
+ bz: number;
79
+ /** Index of the sector the edge belongs to (the side it faces into). */
80
+ front: number;
81
+ /** Index of the sector across a portal, -1 for a solid wall. */
82
+ back: number;
83
+ }
84
+ /** A map ready for physics and drawing. */
85
+ export interface Level {
86
+ map: MapDef;
87
+ /** Every edge of every sector, in sector then point order. */
88
+ edges: Edge[];
89
+ }
90
+ /** One player in a snapshot. */
91
+ export interface SnapPlayer {
92
+ id: number;
93
+ x: number;
94
+ y: number;
95
+ z: number;
96
+ vy: number;
97
+ yaw: number;
98
+ pitch: number;
99
+ ground: boolean;
100
+ /** Seq of the last input of this player the host applied: the client replays the ones after. */
101
+ ack: number;
102
+ }
103
+ export interface Snapshot {
104
+ /** Host tick, u16, wraps. */
105
+ tick: number;
106
+ players: SnapPlayer[];
107
+ }
108
+ /** Room rules, set by the host. Defaults in constants.ts (RULES). */
109
+ export interface Rules {
110
+ maxPlayers: number;
111
+ }
112
+ /**
113
+ * The subset of WebSocket the client relies on. A real WebSocket (with
114
+ * binaryType 'arraybuffer') and the local BroadcastChannel socket both fit.
115
+ */
116
+ export interface SocketLike {
117
+ binaryType: string;
118
+ onopen: (() => void) | null;
119
+ onclose: (() => void) | null;
120
+ onmessage: ((ev: {
121
+ data: string | ArrayBuffer;
122
+ }) => void) | null;
123
+ send(data: string | ArrayBuffer): void;
124
+ close(): void;
125
+ }
126
+ /** Calls cb every ms milliseconds until the returned function is called. */
127
+ export type Every = (cb: () => void, ms: number) => () => void;
128
+ /** One connected peer, as the room sees it. Transport-agnostic. */
129
+ export interface Conn {
130
+ send(data: string | ArrayBuffer): void;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "@korial29/tiny-fps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "description": "Tiny multiplayer first-person shooter for the browser: raw WebGL, zero runtime dependencies, WebSocket or same-browser multi-tab play.",
4
+ "license": "Apache-2.0",
5
+ "version": "0.1.0",
6
+ "type": "module",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/types/client/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./local": {
16
+ "types": "./dist/types/local/index.d.ts",
17
+ "default": "./dist/local.js"
18
+ },
19
+ "./server": {
20
+ "types": "./dist/types/server/index.d.ts",
21
+ "default": "./dist/server.js"
22
+ }
23
+ },
24
+ "bin": {
25
+ "tiny-fps-server": "dist/cli.js"
26
+ },
27
+ "sideEffects": false,
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/korial29/tiny-fps.git"
31
+ },
32
+ "homepage": "https://korial29.github.io/tiny-fps/",
33
+ "keywords": [
34
+ "fps",
35
+ "game",
36
+ "browser-game",
37
+ "html5-game",
38
+ "webgl",
39
+ "multiplayer",
40
+ "realtime",
41
+ "websocket",
42
+ "broadcastchannel",
43
+ "typescript",
44
+ "zero-dependency",
45
+ "js13k"
46
+ ],
47
+ "author": "Ludovic Dupont <ludovic.dupont29@gmail.com>",
48
+ "peerDependencies": {
49
+ "ws": "^8.0.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "ws": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "scripts": {
57
+ "start": "vite demo --config vite.config.ts",
58
+ "dev:server": "tsx watch src/server/cli.ts",
59
+ "build": "rm -rf dist && vite build && BUILD_LOCAL=true vite build && BUILD_SERVER=true vite build && tsc -p tsconfig.build.json",
60
+ "build:demo": "BUILD_DEMO=true vite build",
61
+ "size": "node scripts/size.mjs",
62
+ "lint:eslint": "eslint .",
63
+ "format:eslint": "eslint . --fix",
64
+ "lint:prettier": "prettier --check \"**/*.{js,mjs,ts}\"",
65
+ "format:prettier": "prettier \"**/*.{js,mjs,ts}\" --write",
66
+ "lint:tsc": "tsc --noEmit",
67
+ "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:tsc",
68
+ "format": "npm run format:prettier && npm run format:eslint",
69
+ "test": "vitest run --coverage",
70
+ "test:ci": "vitest run --coverage --passWithNoTests",
71
+ "test:watch": "vitest --watch"
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^26.6.5",
75
+ "@types/ws": "^8.18.2",
76
+ "@vitest/coverage-v8": "^5.0.5",
77
+ "eslint": "^10.13.0",
78
+ "eslint-config-prettier": "^10.1.8",
79
+ "eslint-plugin-prettier": "^5.5.7",
80
+ "jsdom": "^30.1.3",
81
+ "prettier": "^3.9.10",
82
+ "tsx": "^4.23.15",
83
+ "typescript": "^6.0.3",
84
+ "typescript-eslint": "^8.72.0",
85
+ "vite": "^8.3.5",
86
+ "vitest": "^5.0.5",
87
+ "ws": "^8.22.0"
88
+ }
89
+ }