summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json4
-rw-r--r--pnpm-lock.yaml32
-rw-r--r--pnpm-workspace.yaml7
-rw-r--r--src/connection/index.ts197
-rw-r--r--src/game/index.ts31
-rw-r--r--src/run.ts52
-rw-r--r--src/state/const.ts5
-rw-r--r--src/state/index.ts39
-rw-r--r--src/state/types.ts9
-rw-r--r--src/ui/render.ts71
-rw-r--r--src/ui/utils.ts11
11 files changed, 410 insertions, 48 deletions
diff --git a/package.json b/package.json
index 25a346b..b49bdc9 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,9 @@
"clean": "rm -rf api/node_modules && rm -rf frontend/node_modules"
},
"devDependencies": {
+ "@types/node": "^24.0.7",
"tsup": "^8.5.0",
- "tsx": "^4.20.3",
- "@types/node": "^24.0.7"
+ "tsx": "^4.20.3"
},
"dependencies": {
"typescript": "^5.8.3"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bfe48c5..57799e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ promise-socket:
+ specifier: ^8.0.0
+ version: 8.0.0
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -523,6 +526,22 @@ packages:
yaml:
optional: true
+ promise-duplex@8.0.0:
+ resolution: {integrity: sha512-JlrnT6KZMRD+t4cxP7gh/dlGP2pl01/jTPg3pTLvFLBTdHTbTTdc+g25q0qswC3ziO9RAr3L1b2n/++4y23GQg==}
+ engines: {node: '>=16.0.0'}
+
+ promise-readable@8.0.1:
+ resolution: {integrity: sha512-peVAz+49xRooF1Om2wYSOluKpOApnpiJVOKgQ6IR/ZAWo15tBa4ZtcKKSTAytRiNJVlkvil9NoFhqihY4Sl1Fw==}
+ engines: {node: '>=16.0.0'}
+
+ promise-socket@8.0.0:
+ resolution: {integrity: sha512-5Qb5719QcOm3/zrt0DLiAsJXutv2vb4HRxCiOVro1eyAOMak6vF2gTIoB6f0M1mnKMCsXW1SC/O+4G3P6p7kbw==}
+ engines: {node: '>=16.0.0'}
+
+ promise-writable@8.0.0:
+ resolution: {integrity: sha512-Qn7/5tE42fjLUWevPm/SFmVz26tRdhmxmPXgOJSeIwLnkI2V4u0/t+CrRXb8ic1Jy9qqvOHVYm+sAIgel++eVA==}
+ engines: {node: '>=16.0.0'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1026,6 +1045,19 @@ snapshots:
optionalDependencies:
tsx: 4.20.3
+ promise-duplex@8.0.0:
+ dependencies:
+ promise-readable: 8.0.1
+ promise-writable: 8.0.0
+
+ promise-readable@8.0.1: {}
+
+ promise-socket@8.0.0:
+ dependencies:
+ promise-duplex: 8.0.0
+
+ promise-writable@8.0.0: {}
+
punycode@2.3.1: {}
readdirp@4.1.2: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
deleted file mode 100644
index a35699a..0000000
--- a/pnpm-workspace.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-packages:
- - api
- - frontend
-
-onlyBuiltDependencies:
- - core-js
- - core-js-pure
diff --git a/src/connection/index.ts b/src/connection/index.ts
new file mode 100644
index 0000000..828fcb5
--- /dev/null
+++ b/src/connection/index.ts
@@ -0,0 +1,197 @@
+import net, { Socket } from "node:net";
+import { SessionState } from "../state";
+import { TextEncoder } from "node:util";
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+const Headers = {
+ BEGIN_FRAME: Buffer.from(encoder.encode("BEGIN_FRAME")),
+ END_FRAME: Buffer.from(encoder.encode("END_FRAME")),
+ STATE_MSG: Buffer.from(encoder.encode("BEGIN_STATE_MSG")),
+};
+
+export class Connection {
+ private readBuffer: Buffer = Buffer.alloc(0);
+ private frameBuffer: Uint8Array[] = [];
+
+ private handleData(data: Buffer) {
+ // append to buffer
+ this.readBuffer = Buffer.concat([this.readBuffer, data]);
+
+ while (true) {
+ if (this.readBuffer.length < Headers.BEGIN_FRAME.length + 4) {
+ return;
+ }
+
+ if (
+ !this.readBuffer
+ .subarray(0, Headers.BEGIN_FRAME.length)
+ .equals(Headers.BEGIN_FRAME)
+ ) {
+ throw new Error("Missing BEGIN_FRAME!");
+ }
+
+ const lengthOffset = Headers.BEGIN_FRAME.length;
+ const messageLength = this.readBuffer.readUInt32LE(lengthOffset);
+
+ const totalFrameLength =
+ Headers.BEGIN_FRAME.length +
+ 4 +
+ messageLength +
+ Headers.END_FRAME.length;
+
+ if (this.readBuffer.length < totalFrameLength) {
+ return;
+ }
+
+ const messageStart = lengthOffset + 4;
+ const messageEnd = messageStart + messageLength;
+ const message = this.readBuffer.subarray(messageStart, messageEnd);
+
+ const endFrameStart = messageEnd;
+ if (
+ !this.readBuffer
+ .subarray(endFrameStart, endFrameStart + Headers.END_FRAME.length)
+ .equals(Headers.END_FRAME)
+ ) {
+ console.log(decoder.decode(this.readBuffer));
+ console.log(
+ this.readBuffer.subarray(
+ endFrameStart,
+ endFrameStart + Headers.END_FRAME.length
+ )
+ );
+ throw new Error("Invalid END_FRAME!");
+ }
+
+ this.frameBuffer.push(message);
+
+ this.readBuffer = this.readBuffer.subarray(totalFrameLength);
+ }
+ }
+
+ constructor(private socket: Socket) {
+ socket.on("data", this.handleData.bind(this));
+ }
+
+ private serializeState(state: SessionState): Uint8Array {
+ const json = JSON.stringify(state);
+ return encoder.encode(json);
+ }
+
+ private deserializeState(state: Uint8Array): SessionState {
+ const json = decoder.decode(state);
+ return JSON.parse(json);
+ }
+
+ public sendState(state: SessionState) {
+ const serializedState = this.serializeState(state);
+ const message = new Uint8Array([...Headers.STATE_MSG, ...serializedState]);
+
+ const len = Buffer.alloc(4);
+ len.writeUInt32LE(message.length);
+
+ const msg = new Uint8Array([
+ ...Headers.BEGIN_FRAME,
+ ...len,
+ ...message,
+ ...Headers.END_FRAME,
+ ]);
+
+ this.socket.write(msg);
+ }
+
+ public tryReadState(): SessionState | undefined {
+ const frame = this.frameBuffer.find((frame) => {
+ return Headers.STATE_MSG.every((v, i) => frame[i] === v);
+ });
+
+ if (!frame) {
+ return;
+ }
+
+ this.frameBuffer.splice(this.frameBuffer.indexOf(frame), 1);
+
+ const msg = frame.slice(Headers.STATE_MSG.length);
+ const state = this.deserializeState(msg);
+
+ return state;
+ }
+}
+
+export const advanceStateRemote = async (
+ connection: Connection,
+ sessionState: SessionState
+): Promise<SessionState> => {
+ const remoteState = await new Promise<SessionState>((res, rej) => {
+ const timeout = setTimeout(rej, 2500);
+ const interval = setInterval(async () => {
+ const state = connection.tryReadState();
+ if (state) {
+ clearInterval(timeout);
+ clearInterval(interval);
+ res(state);
+ }
+ });
+ });
+
+ if (remoteState.seqno !== sessionState.seqno) {
+ throw new Error(`Misaligned seqno!`);
+ }
+ return {
+ ...sessionState,
+ inboundEventQueue: remoteState.outboundEventQueue,
+ remotePlayerGameState: remoteState.localPlayerGameState,
+ };
+};
+
+export const getConnection = async ({
+ localPort,
+ remotePort,
+ hostname,
+}: {
+ localPort?: number;
+ remotePort?: number;
+ hostname?: string;
+}) => {
+ return new Promise<Connection>(async (res, rej) => {
+ let serverSocket: Socket | undefined;
+ let clientSocket: Socket | undefined;
+
+ const handleConnection = () => {
+ if (serverSocket) {
+ res(new Connection(serverSocket));
+ return;
+ } else if (clientSocket) {
+ res(new Connection(clientSocket));
+ return;
+ }
+
+ throw new Error("No valid connection!");
+ };
+
+ if (remotePort) {
+ console.log(`Trying to connect on ${remotePort}`);
+ if (!hostname) {
+ throw new Error(
+ `A hostname is required when connecting to a remote machine`
+ );
+ }
+ clientSocket = net.createConnection(
+ remotePort,
+ hostname,
+ handleConnection
+ );
+ } else if (localPort) {
+ console.log(`Listening on ${localPort}`);
+ const server = net.createServer((socket) => {
+ serverSocket = socket;
+ handleConnection();
+ });
+ server.listen(localPort);
+ } else {
+ throw new Error(`Must provide localPort OR remotePort`);
+ }
+ });
+};
diff --git a/src/game/index.ts b/src/game/index.ts
index 3ab20ca..62debf0 100644
--- a/src/game/index.ts
+++ b/src/game/index.ts
@@ -1,15 +1,22 @@
-import { GAME_SIZE, PADDLE_HEIGHT, PADDLE_WIDTH, SessionState } from "../state";
-import { VELOCITY_SCALING_FACTOR } from "./const";
+import {
+ createRandomBall,
+ Event,
+ GAME_SIZE,
+ PADDLE_HEIGHT,
+ PADDLE_WIDTH,
+ SessionState,
+} from "../state";
import { Action, Collider } from "./types";
import { applyBallBounce, applyBallVelocity } from "./utils";
-export const advanceState = async (
+export const advanceStateLocal = async (
curState: SessionState,
action: Action | undefined
): Promise<SessionState> => {
- //simulate network
+ //minimum delay
await new Promise((res) => setTimeout(res, 15));
+ let candidateOutboundEventQueue: Event[] = [];
let candidatePaddle = curState.localPlayerGameState.paddle;
if (action === Action.MOVE_LEFT) {
@@ -48,7 +55,10 @@ export const advanceState = async (
max: pos + 0.5,
})) as Collider["boundingBox"],
normal: [0, 1],
- onHit: () => candidateBricks.splice(i, 1),
+ onHit: () => {
+ candidateOutboundEventQueue.push({ name: "NEW_BALL" });
+ candidateBricks.splice(i, 1);
+ },
});
});
@@ -101,13 +111,24 @@ export const advanceState = async (
})
.filter((ball) => !!ball);
+ //apply incoming events
+ for (const event of curState.inboundEventQueue) {
+ switch (event.name) {
+ case "NEW_BALL":
+ candidateBalls.push(createRandomBall());
+ }
+ }
+
return {
...curState,
+ inboundEventQueue: [],
+ outboundEventQueue: candidateOutboundEventQueue,
localPlayerGameState: {
...curState.localPlayerGameState,
bricks: candidateBricks,
paddle: candidatePaddle,
balls: candidateBalls,
},
+ seqno: curState.seqno + 1,
};
};
diff --git a/src/run.ts b/src/run.ts
index c798f87..1890303 100644
--- a/src/run.ts
+++ b/src/run.ts
@@ -1,10 +1,15 @@
-import { advanceState } from "./game";
+import readline from "node:readline/promises";
+import { advanceStateRemote, Connection, getConnection } from "./connection";
+import { advanceStateLocal } from "./game";
import { Action } from "./game/types";
-import { createSessionState, SessionState } from "./state";
+import {
+ createLocalSessionState,
+ createNetworkedSessionState,
+ SessionState,
+} from "./state";
import { renderState, createAndBindHandler, prepareTerminal, Key } from "./ui";
export const run = async () => {
- let state: SessionState = createSessionState("xyz");
let actionQueue: Action[] = [];
const updateAction = (key: Key) => {
@@ -23,13 +28,48 @@ export const run = async () => {
}
};
- prepareTerminal();
-
createAndBindHandler(updateAction, process.exit);
+ let connection: Connection;
+ const rl = readline.createInterface(process.stdin, process.stdout);
+ const solo = ["y", ""].includes(
+ (await rl.question("Play solo? (Y/n) > ")).trim().toLowerCase()
+ );
+
+ let state: SessionState;
+
+ if (!solo) {
+ state = createNetworkedSessionState("NETWORKED-SESSION");
+ const host = ["y", ""].includes(
+ (await rl.question("Host? (Y/n) > ")).trim().toLowerCase()
+ );
+ if (host) {
+ console.log(`Starting the server...`);
+ connection = await getConnection({ localPort: 9932 });
+ } else {
+ const hostname =
+ (await rl.question("Enter remote hostname [localhost] > ")).trim() ||
+ "localhost";
+ connection = await getConnection({ remotePort: 9932, hostname });
+ }
+
+ try {
+ } catch {}
+ } else {
+ state = createLocalSessionState("LOCAL-GAME");
+ }
+
+ prepareTerminal();
+
while (true) {
let nextAction = actionQueue.pop();
- state = await advanceState(state, nextAction);
+
+ state = await advanceStateLocal(state, nextAction);
+ if (state.remotePlayerGameState) {
+ connection!.sendState(state);
+ state = await advanceStateRemote(connection!, state);
+ }
+
renderState(state);
}
};
diff --git a/src/state/const.ts b/src/state/const.ts
index caac8ce..380eb99 100644
--- a/src/state/const.ts
+++ b/src/state/const.ts
@@ -8,4 +8,7 @@ export const INITIAL_PADDLE_POSITION = 10;
export const PADDLE_WIDTH = 3;
export const PADDLE_HEIGHT = 1;
-export const NUM_STARTING_BALLS = 2;
+export const NUM_STARTING_BALLS = 1;
+
+export const NUM_BRICKS = GAME_SIZE.cols * 5;
+export const BRICK_DISTRIBUTION = 0.9;
diff --git a/src/state/index.ts b/src/state/index.ts
index 03d218f..6999188 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -1,10 +1,15 @@
-import { GAME_SIZE, NUM_STARTING_BALLS } from "./const";
-import { Ball, GameState, SessionState } from "./types";
+import {
+ BRICK_DISTRIBUTION,
+ GAME_SIZE,
+ NUM_BRICKS,
+ NUM_STARTING_BALLS,
+} from "./const";
+import { Ball, Brick, GameState, SessionState } from "./types";
export * from "./const";
export * from "./types";
-const createRandomBall = (): Ball => ({
+export const createRandomBall = (): Ball => ({
position: [
GAME_SIZE.cols / 4 + (Math.random() * GAME_SIZE.cols) / 2,
GAME_SIZE.rows / 4 + (Math.random() * GAME_SIZE.rows) / 2,
@@ -18,14 +23,34 @@ const createGameState = () =>
balls: new Array(NUM_STARTING_BALLS)
.fill(undefined)
.map(() => createRandomBall()),
- bricks: new Array(GAME_SIZE.cols * 5).fill(undefined).map((_, i) => ({
- position: [i % GAME_SIZE.cols, Math.floor(i / GAME_SIZE.cols)],
- })),
+ bricks: new Array(NUM_BRICKS)
+ .fill(undefined)
+ .map((_, i) =>
+ Math.random() < BRICK_DISTRIBUTION
+ ? ({
+ position: [i % GAME_SIZE.cols, Math.floor(i / GAME_SIZE.cols)],
+ } as Brick)
+ : undefined
+ )
+ .filter((b) => !!b),
} satisfies GameState);
-export const createSessionState = (sessionId: string) =>
+export const createLocalSessionState = (sessionId: string) =>
+ ({
+ sessionId,
+ seqno: 0,
+ localPlayerGameState: createGameState(),
+ remotePlayerGameState: undefined,
+ inboundEventQueue: [],
+ outboundEventQueue: [],
+ } satisfies SessionState);
+
+export const createNetworkedSessionState = (sessionId: string) =>
({
sessionId,
+ seqno: 0,
localPlayerGameState: createGameState(),
remotePlayerGameState: createGameState(),
+ inboundEventQueue: [],
+ outboundEventQueue: [],
} satisfies SessionState);
diff --git a/src/state/types.ts b/src/state/types.ts
index 202e65e..517be17 100644
--- a/src/state/types.ts
+++ b/src/state/types.ts
@@ -18,8 +18,15 @@ export type GameState = {
bricks: Brick[];
};
+export type NewBallEvent = { name: "NEW_BALL" };
+
+export type Event = NewBallEvent;
+
export type SessionState = {
sessionId: string;
+ seqno: number;
localPlayerGameState: GameState;
- remotePlayerGameState: GameState;
+ remotePlayerGameState: GameState | undefined;
+ inboundEventQueue: Event[];
+ outboundEventQueue: Event[];
};
diff --git a/src/ui/render.ts b/src/ui/render.ts
index 95464dc..79451ae 100644
--- a/src/ui/render.ts
+++ b/src/ui/render.ts
@@ -3,15 +3,19 @@ import readline from "node:readline";
import {
clearTerminal,
getCurrentTerminalSize,
- TERM_SIZE as RENDER_SIZE,
+ RENDER_GAME_SIZE,
+ RENDER_STATE_SIZE,
} from "./utils";
let lastTermSize: ReturnType<typeof getCurrentTerminalSize> | undefined;
+let lastRender: { seqno: number; time: number } = {
+ seqno: 0,
+ time: Date.now(),
+};
export const renderGameState = (gameState: GameState): string[] => {
let rows: string[] = [];
for (let row = -1; row < GAME_SIZE.rows + 1; row++) {
- // let rowOut: string = " ".repeat(marginCols);
let rowOut: string = " ";
if (row === -1) {
@@ -86,28 +90,63 @@ export const renderState = (sessionState: SessionState) => {
}
lastTermSize = termSize;
- if (termSize.cols < RENDER_SIZE.cols || termSize.rows < RENDER_SIZE.rows) {
+ if (
+ termSize.cols < RENDER_STATE_SIZE.cols ||
+ termSize.rows < RENDER_STATE_SIZE.rows
+ ) {
process.stdout.write("Please increase the screen size");
return;
}
- const marginCols = (termSize.cols - RENDER_SIZE.cols) / 2;
- const marginRows = (termSize.rows - RENDER_SIZE.rows) / 2;
+ const marginCols = termSize.cols - RENDER_STATE_SIZE.cols;
+ const marginRows = (termSize.rows - RENDER_STATE_SIZE.rows) / 2;
let allOut: string = "\n".repeat(marginRows);
const localDisplay = renderGameState(sessionState.localPlayerGameState);
- const remoteDisplay = renderGameState(sessionState.remotePlayerGameState);
-
- localDisplay.forEach(
- (row, i) =>
- (allOut = allOut
- .concat(" ".repeat(marginCols / 2))
- .concat(row)
- .concat(" ".repeat(marginCols / 2))
- .concat(remoteDisplay[i])
- .concat("\n"))
- );
+ const remoteDisplay =
+ sessionState.remotePlayerGameState &&
+ renderGameState(sessionState.remotePlayerGameState);
+
+ const timeNow = Date.now();
+ const infoHeader = `Frame: ${sessionState.seqno}
+Session: ${sessionState.sessionId}
+Fps: ${(
+ ((sessionState.seqno - lastRender.seqno) / (timeNow - lastRender.time)) *
+ 1000
+ ).toFixed(0)}\n`;
+
+ if (remoteDisplay) {
+ allOut = allOut
+ .concat(infoHeader)
+ .concat(" ".repeat(marginCols / 2))
+ .concat(`LOCAL:`)
+ .concat(" ".repeat(RENDER_GAME_SIZE.cols))
+ .concat(" ".repeat(4))
+ .concat(`REMOTE:`)
+ .concat("\n");
+ localDisplay.forEach(
+ (row, i) =>
+ (allOut = allOut
+ .concat(" ".repeat(marginCols / 2))
+ .concat(row)
+ .concat(" ".repeat(8))
+ .concat(remoteDisplay[i])
+ .concat(" ".repeat(marginCols / 2))
+ .concat("\n"))
+ );
+ } else {
+ allOut = allOut.concat(infoHeader).concat("\n");
+ localDisplay.forEach(
+ (row, i) =>
+ (allOut = allOut
+ .concat(" ".repeat(marginCols / 2 + 4))
+ .concat(row)
+ .concat(" ".repeat(marginCols / 2 + 4))
+ .concat("\n"))
+ );
+ }
process.stdout.write(allOut);
+ lastRender = { seqno: sessionState.seqno, time: timeNow };
};
diff --git a/src/ui/utils.ts b/src/ui/utils.ts
index 32de02e..9f5e8bc 100644
--- a/src/ui/utils.ts
+++ b/src/ui/utils.ts
@@ -1,9 +1,14 @@
import process from "node:process";
import { GAME_SIZE } from "../state";
-export const TERM_SIZE = {
- rows: GAME_SIZE.rows + 2,
- cols: (GAME_SIZE.cols * 2 + 2) * 2,
+export const RENDER_GAME_SIZE = {
+ rows: GAME_SIZE.rows,
+ cols: GAME_SIZE.cols * 2 + 4,
+};
+
+export const RENDER_STATE_SIZE = {
+ rows: RENDER_GAME_SIZE.rows + 7,
+ cols: RENDER_GAME_SIZE.cols * 2 + 10,
};
export const getCurrentTerminalSize = (): { rows: number; cols: number } => {