Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": false,
"trailingComma": "none"
}
Comment thread
iamEvanYT marked this conversation as resolved.
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
# Repository Guidelines

## Project Structure & Module Organization

- `src/`: TypeScript source. Key areas: `routes/` (HTTP endpoints; auto-mounted), `modules/` (config, DB, matchmaking), `middlewares/` (logging, parsing, errors), `schemas/` (zod), `types/` (shared types). Entry: `src/index.ts`.
- `tests/`: Manual scripts for local testing (`tests/manual/*.ts`).
- `Dockerfile`: Container entrypoint using Bun.

## Build, Test, and Development Commands

- `bun run dev`: Start in watch mode (`bun --watch src/index.ts`).
- `bun run start`: Run the server once (uses Bun).
- `bun run build`: Type-check via `tsc` (no emit).
- Example health check: `curl http://localhost:3000/v1/healthcheck`.
- Manual test scripts: `bun tests/manual/join-queue.ts`, `bun tests/manual/leave-queue.ts`.

## Coding Style & Naming Conventions

- Language: TypeScript (ES modules, target `es2020`).
- Indentation: 2 spaces; keep formatting consistent with existing files.
- Filenames: routes and middlewares use kebab-case (e.g., `join-queue.ts`); types/interfaces PascalCase within code; variables camelCase.
- Validation: zod schemas in `src/schemas`; reuse shared types from `src/types`.

## Testing Guidelines

- Framework: none configured; use manual scripts under `tests/manual/` to exercise endpoints.
- Add new manual tests near related features, named after the route (e.g., `tests/manual/<route>.ts`).
- Targeted checks: verify `/v1/healthcheck`, queue join/leave flows, and expected JSON shapes.

## Commit & Pull Request Guidelines

- Commit style: prefer Conventional Commits (`feat:`, `fix:`, `chore:`) as used in history.
- PRs: include clear description, linked issues, and sample requests/responses (curl or script output). Note any config or migration steps.
- Keep changes small and focused; update README/this guide when structure or commands change.

## Security & Configuration Tips

- Copy `.env.example` to `.env`. Key vars: `AuthKey` (API auth), `MongoUrl` (default `mongodb://localhost:27017`), `Port` (default `3000`), `Environment`, `Instances`, `MATCHMAKING_ENABLED`.
- Start MongoDB locally and ensure indexes are created at startup.
- Use the `AuthKey` in requests where required.

## Architecture Overview

- Server: Hono app with route auto-loading from `src/routes/**`.
- Data: MongoDB; collections configured in `modules/config.ts`.
- Matchmaking: logic under `modules/matchmaking/**`; enable via `MATCHMAKING_ENABLED=true`.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ GameMatcher/
## Documentation

See the docs folder for usage guides:

- docs/quickstart.md — local run and development
- docs/api.md — endpoint reference and examples
- docs/roblox.md — integrate from Roblox (Luau)
Expand Down
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
- [Quickstart](./quickstart.md)
- [API Reference](./api.md)
- [Configuration](./configuration.md)
- [Matchmaking Architecture](./matchmaking.md)
- [Handling Multiple Nodes](./nodes.md)
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ export const queues: QueueConfigs = [
teamsPerMatch: 2,
discoverMatchesInterval: 5,
searchRange: [0, 0],
incrementRange: [1, 1],
},
incrementRange: [1, 1]
}
] as const;
```

Expand Down
44 changes: 44 additions & 0 deletions docs/matchmaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Matchmaking Architecture

This project uses a modular architecture to organize matchmaking logic and make it easy to extend, test, and maintain.

## Algorithms

Supported queue algorithms:

- normal: Fixed team size, no ranking. Packs parties greedily to fill teams.
- dynamic: Team size adapts between min/max based on oldest party wait time.
- ranked: Uses party `rankedValue` with a search range that expands over time.

Algorithms live in `src/modules/matchmaking/algorithms/*` and implement a common interface.

## Interface and Registry

- Algorithm interface: `Algorithm<T extends QueueConfig> = (cfg: T, services: Services) => Promise<void>`
- Services provide DB access and utilities, keeping algorithm code pure and testable:
- `getOldestParties(queueId, limit)`
- `updatePartyRange(partyId, rankedMin, rankedMax)`
- `createMatch(queueConfig, teams, partiesUsed)`
- `now()` and `log()`
- A registry maps `queueType` to the corresponding algorithm.

## Sharding (Regions)

Requests can include `shardId` to restrict matching to a region. The server processes queues per-shard. Each queue has `shardTimeoutSeconds` to control expansion:

- `-1`: disables sharding (global matching only)
- `>= 0`: match within shard until the oldest party waits this many seconds; then an expanded global pass can combine parties from all shards

Indexes support both per-shard and global scans for efficient matching.

## Configuration Summary

See docs/configuration.md for complete details. Key fields:

- All queues: `queueId`, `queueType`, `usersPerTeam`, `teamsPerMatch`, `discoverMatchesInterval`, `shardTimeoutSeconds`
- Ranked: `searchRange`, `incrementRange`, `incrementRangeMax?`
- Dynamic: `minUsersPerTeam`, `maxUsersPerTeam`, `timeElaspedToUseMinimumUsers`

## API Summary

See docs/api.md for request/response formats. To target a region, set `shardId` in `POST /v1/join-queue`.
Comment thread
iamEvanYT marked this conversation as resolved.
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
"scripts": {
"start": "bun src/index.ts",
"build": "tsc",
"dev": "bun --watch src/index.ts"
"dev": "bun --watch src/index.ts",
"format": "prettier --write ."
},
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"prettier": "^3.6.2"
},
"peerDependencies": {
"typescript": "^5.6.3"
Expand All @@ -20,4 +22,4 @@
"zlib": "^1.0.5",
"zod": "^3.23.8"
}
}
}
86 changes: 43 additions & 43 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import process from 'node:process';
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
import process from "node:process";

const numCPUs = availableParallelism();

if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);

// Spawn n-1 processes if not specified.
let instances = (numCPUs - 1)
let instances = numCPUs - 1;
if (process.env.Instances) {
instances = parseInt(process.env.Instances)
instances = parseInt(process.env.Instances);
}

if (process.versions.bun) {
Expand All @@ -23,42 +23,42 @@ if (cluster.isPrimary) {
cluster.fork();
}

cluster.on('exit', (worker, code, signal) => {
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died`);
process.exit()
process.exit();
});
} else {
console.log(`Worker ${process.pid} started`);
}

import { Hono } from 'hono'
import { trimTrailingSlash } from 'hono/trailing-slash'
import { parseGzippedJson } from 'middlewares/parse-gzip-json.js';
import { logging } from 'middlewares/logging.js';
import { createIndexes } from 'modules/database-indexes.js';
import { port } from 'modules/config.js';
import { errorHandler } from 'middlewares/error-handler.js';
import { Hono } from "hono";
import { trimTrailingSlash } from "hono/trailing-slash";
import { parseGzippedJson } from "middlewares/parse-gzip-json.js";
import { logging } from "middlewares/logging.js";
import { createIndexes } from "modules/database-indexes.js";
import { port } from "modules/config.js";
import { errorHandler } from "middlewares/error-handler.js";

import fs from "fs";
import path from "path";
import { client } from 'modules/database.js';
import { initMatchmaking } from 'modules/matchmaking/matchmaking.js';
import { client } from "modules/database.js";
import { initMatchmaking } from "modules/matchmaking/matchmaking.js";

// Initialize Hono app
const app = new Hono()
app.use(trimTrailingSlash())
app.use(parseGzippedJson)
app.use(logging)
const app = new Hono();
app.use(trimTrailingSlash());
app.use(parseGzippedJson);
app.use(logging);

// Create Indexes
createIndexes();

// Error handling middleware
app.onError(errorHandler)
app.onError(errorHandler);

// Start matchmaking if needed
if (process.env.MATCHMAKING_ENABLED === 'true') {
initMatchmaking()
if (process.env.MATCHMAKING_ENABLED === "true") {
initMatchmaking();
}

// Function to recursively import routes
Expand All @@ -77,55 +77,55 @@ function importRoutes(folderPath, baseRoute = "") {
} else if (file.endsWith(".js") || file.endsWith(".ts")) {
// Import and mount JavaScript files as routes
const requiredRoutes = await import(filePath);
const { routes } = requiredRoutes
const { routes } = requiredRoutes;

const routeName = file === "index.js" ? "" : file === "index.ts" ? "" : path.parse(file).name;
const fullRoute = path.join(baseRoute, routeName);
app.route(fullRoute, routes);
}
Comment thread
iamEvanYT marked this conversation as resolved.
}
};
files.forEach((file) => {
importPromises.push(importFile(file))
importPromises.push(importFile(file));
});

return Promise.all(importPromises)
return Promise.all(importPromises);
}

// Import all routes from the routes folder
let fileDirectory = __dirname
let fileDirectory = __dirname;
if (process.versions.bun) {
fileDirectory = import.meta.dir
fileDirectory = import.meta.dir;
}

Comment thread
iamEvanYT marked this conversation as resolved.
const apiFolder = path.join(fileDirectory, "routes");
await importRoutes(apiFolder, "");

app.get('/', (c) => c.text('Are you lost?', 404))
app.get('/*', (c) => c.text('Are you lost?', 404))
app.get("/", (c) => c.text("Are you lost?", 404));
app.get("/*", (c) => c.text("Are you lost?", 404));

// Start the server
console.log(`Matchmaking Backend (Process ${process.pid}) listening on port ${port}`)
console.log(`Matchmaking Backend (Process ${process.pid}) listening on port ${port}`);

// Graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down gracefully...')
await client.close()
console.log('MongoDB connection closed')
process.exit(0)
})
process.on("SIGINT", async () => {
console.log("Shutting down gracefully...");
await client.close();
console.log("MongoDB connection closed");
process.exit(0);
});

let serveOptions: any = {
port,
fetch: app.fetch,
}
fetch: app.fetch
};

if (process.versions.bun) {
serveOptions.reusePort = true
Bun.serve(serveOptions)
serveOptions.reusePort = true;
Bun.serve(serveOptions);
} else {
if (!cluster.isWorker) {
import("@hono/node-server").then(({ serve: serveNodeJS }) => {
serveNodeJS(serveOptions);
});
}
}
}
58 changes: 29 additions & 29 deletions src/middlewares/body-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,34 @@ export type ContextWithParsedBody<Schema extends ZodTypeAny = ZodTypeAny> = Cont
};

type JSONBodyParserOptions<T extends ZodTypeAny> = {
schema?: T;
}
schema?: T;
};

export function parseJSONBody<T extends ZodTypeAny>({ schema }: JSONBodyParserOptions<T>): MiddlewareHandler {
return async (c: Context, next: Next) => {
var failedToParse = false
const jsonBody = await c.req.json().catch(() => {
failedToParse = true;
});

if (failedToParse) {
return c.json({ error: "FailedToParseBody" }, 400);
}

const ctx = c as ContextWithParsedBody<T>;

if (schema) {
const parseResult = schema.safeParse(jsonBody);
if (!parseResult.success) {
return c.json({ error: parseResult.error.errors }, 400);
}

ctx.bodyData = parseResult.data;
} else {
ctx.bodyData = jsonBody;
}

await next();
return;
};
}
return async (c: Context, next: Next) => {
var failedToParse = false;
const jsonBody = await c.req.json().catch(() => {
failedToParse = true;
});

if (failedToParse) {
return c.json({ error: "FailedToParseBody" }, 400);
}

const ctx = c as ContextWithParsedBody<T>;

if (schema) {
const parseResult = schema.safeParse(jsonBody);
if (!parseResult.success) {
return c.json({ error: parseResult.error.errors }, 400);
}

ctx.bodyData = parseResult.data;
} else {
ctx.bodyData = jsonBody;
}

await next();
return;
};
}
6 changes: 3 additions & 3 deletions src/middlewares/error-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Context } from "hono";

export const errorHandler = (err: Error, c: Context) => {
console.error(err)
return c.json({ error: 'Internal Server Error' }, 500)
}
console.error(err);
return c.json({ error: "Internal Server Error" }, 500);
};
Loading