Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
+22
View File
@@ -0,0 +1,22 @@
using Microsoft.Extensions.Configuration;
var builder = DistributedApplication.CreateBuilder(args);
var server = builder.AddProject<Projects.HSchool_Server>("server")
.WithHttpHealthCheck("/health")
.WithExternalHttpEndpoints();
// Integration tests and CI run headless: no Node, no dev server, just the game server.
var headless = builder.Configuration.GetValue("HSchool:Headless", false);
if (!headless)
{
var client = builder.AddViteApp("client", "../HSchool.Client")
.WithReference(server)
.WaitFor(server);
// On publish the built client is copied into the server image and served from wwwroot.
server.PublishWithContainerFiles(client, "wwwroot");
}
builder.Build().Run();
@@ -0,0 +1,19 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>HSchool.AppHost</RootNamespace>
<UserSecretsId>hschool-apphost-8f2c1d4a</UserSecretsId>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
<PackageReference Include="Aspire.Hosting.JavaScript" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Server\HSchool.Server.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,32 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17180;http://localhost:15180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21180",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23180",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22180"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19180",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18180",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20180"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>h-school</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="stage"></div>
<div id="hud">
<span data-hud="status">connecting…</span>
<span data-hud="tick">tick 0</span>
<span data-hud="ping">-- ms</span>
<span data-hud="entities">0 entities</span>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "hschool-client",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"pixi.js": "^8.19.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "~5.9.3",
"vite": "^8.2.1",
"vitest": "^4.1.10"
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { ConnectionStatus } from '../net/connection.ts';
/** Thin wrapper over the status line in `index.html`. */
export class Hud {
private readonly fields = new Map<string, HTMLElement>();
constructor(root: ParentNode = document) {
for (const element of root.querySelectorAll<HTMLElement>('[data-hud]')) {
this.fields.set(element.dataset['hud'] ?? '', element);
}
}
setStatus(status: ConnectionStatus): void {
this.set('status', status);
}
setTick(tick: number): void {
this.set('tick', `tick ${tick}`);
}
setPing(rttMs: number): void {
this.set('ping', `${Math.round(rttMs)} ms`);
}
setEntityCount(count: number): void {
this.set('entities', `${count} entities`);
}
private set(field: string, text: string): void {
const element = this.fields.get(field);
if (element !== undefined) {
element.textContent = text;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { InputButtons } from '../net/protocol.ts';
/** How often the button mask is pushed to the server, independent of the render rate. */
export const INPUT_SEND_HZ = 30;
const KEY_BINDINGS: Readonly<Record<string, number>> = {
KeyW: InputButtons.Up,
ArrowUp: InputButtons.Up,
KeyS: InputButtons.Down,
ArrowDown: InputButtons.Down,
KeyA: InputButtons.Left,
ArrowLeft: InputButtons.Left,
KeyD: InputButtons.Right,
ArrowRight: InputButtons.Right,
};
/** Tracks the keyboard and pushes the current mask on a fixed cadence. */
export class InputTracker {
private buttons = InputButtons.None;
private timer: ReturnType<typeof setInterval> | null = null;
private readonly onKeyDown = (event: KeyboardEvent): void => {
const button = KEY_BINDINGS[event.code];
if (button !== undefined) {
this.buttons |= button;
event.preventDefault();
}
};
private readonly onKeyUp = (event: KeyboardEvent): void => {
const button = KEY_BINDINGS[event.code];
if (button !== undefined) {
this.buttons &= ~button;
event.preventDefault();
}
};
// Alt-tabbing away must not leave a key stuck down.
private readonly onBlur = (): void => {
this.buttons = InputButtons.None;
};
constructor(private readonly send: (buttons: number) => void) {}
get current(): number {
return this.buttons;
}
start(target: Window = window): void {
target.addEventListener('keydown', this.onKeyDown);
target.addEventListener('keyup', this.onKeyUp);
target.addEventListener('blur', this.onBlur);
this.timer = setInterval(() => this.send(this.buttons), 1000 / INPUT_SEND_HZ);
}
stop(target: Window = window): void {
target.removeEventListener('keydown', this.onKeyDown);
target.removeEventListener('keyup', this.onKeyUp);
target.removeEventListener('blur', this.onBlur);
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import { Application, Container, Graphics } from 'pixi.js';
import { EntityKind, type EntitySnapshot } from '../net/protocol.ts';
const FIELD_BORDER_COLOR = 0x2a3242;
const OWN_PLAYER_RING_COLOR = 0xffffff;
/**
* Draws the interpolated world state. One PixiJS `Graphics` per replicated entity,
* created on first sight and destroyed when the entity disappears from a snapshot.
*/
export class WorldRenderer {
private readonly world = new Container();
private readonly field = new Graphics();
private readonly sprites = new Map<number, Graphics>();
private worldWidth = 1600;
private worldHeight = 900;
private ownEntityId = 0;
constructor(private readonly app: Application) {
this.world.addChild(this.field);
this.app.stage.addChild(this.world);
this.app.renderer.on('resize', () => this.layout());
}
/** Called on every welcome frame: the server owns the field size. */
configure(worldWidth: number, worldHeight: number, ownEntityId: number): void {
this.worldWidth = worldWidth;
this.worldHeight = worldHeight;
this.ownEntityId = ownEntityId;
this.field
.clear()
.rect(0, 0, worldWidth, worldHeight)
.stroke({ color: FIELD_BORDER_COLOR, width: 4 });
this.layout();
}
draw(entities: readonly EntitySnapshot[]): void {
const seen = new Set<number>();
for (const entity of entities) {
seen.add(entity.id);
let sprite = this.sprites.get(entity.id);
if (sprite === undefined) {
sprite = this.createSprite(entity);
this.sprites.set(entity.id, sprite);
this.world.addChild(sprite);
}
sprite.x = entity.x;
sprite.y = entity.y;
}
for (const [id, sprite] of this.sprites) {
if (!seen.has(id)) {
sprite.destroy();
this.sprites.delete(id);
}
}
}
private createSprite(entity: EntitySnapshot): Graphics {
const sprite = new Graphics();
if (entity.kind === EntityKind.Obstacle) {
sprite.roundRect(-entity.radius, -entity.radius, entity.radius * 2, entity.radius * 2, 8);
} else {
sprite.circle(0, 0, entity.radius);
}
sprite.fill({ color: entity.color });
if (entity.id === this.ownEntityId) {
sprite.circle(0, 0, entity.radius + 6).stroke({ color: OWN_PLAYER_RING_COLOR, width: 2, alpha: 0.9 });
}
return sprite;
}
/** Fits the whole field on screen with letterboxing, so every client sees the same area. */
private layout(): void {
const { width, height } = this.app.renderer.screen;
const scale = Math.min(width / this.worldWidth, height / this.worldHeight) * 0.95;
this.world.scale.set(scale);
this.world.x = (width - this.worldWidth * scale) / 2;
this.world.y = (height - this.worldHeight * scale) / 2;
}
/** Drops every sprite, e.g. after a reconnect assigns new replication ids. */
reset(): void {
for (const sprite of this.sprites.values()) {
sprite.destroy();
}
this.sprites.clear();
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { SnapshotBuffer } from './snapshotBuffer.ts';
import { EntityKind, type SnapshotMessage } from '../net/protocol.ts';
function snapshot(tick: number, x: number): SnapshotMessage {
return {
type: 'snapshot',
tick,
entities: [{ id: 1, kind: EntityKind.Player, x, y: 0, radius: 10, color: 0xffffff }],
};
}
describe('SnapshotBuffer', () => {
it('returns nothing before the first snapshot', () => {
expect(new SnapshotBuffer().sample(1000)).toEqual([]);
});
it('blends the two snapshots straddling the render time', () => {
const buffer = new SnapshotBuffer(100);
buffer.push(snapshot(1, 0), 1000);
buffer.push(snapshot(2, 100), 1100);
// Render time 1050 sits halfway between the two receive timestamps.
const entities = buffer.sample(1150);
expect(entities[0]?.x).toBeCloseTo(50);
});
it('holds at the newest snapshot when the render time has caught up', () => {
const buffer = new SnapshotBuffer(0);
buffer.push(snapshot(1, 0), 1000);
buffer.push(snapshot(2, 100), 1100);
expect(buffer.sample(5000)[0]?.x).toBe(100);
expect(buffer.latestTick).toBe(2);
});
it('drops the history when the tick goes backwards after a reconnect', () => {
const buffer = new SnapshotBuffer(100);
buffer.push(snapshot(50, 500), 1000);
buffer.push(snapshot(1, 0), 2000);
expect(buffer.size).toBe(1);
expect(buffer.latestTick).toBe(1);
});
});
@@ -0,0 +1,109 @@
import type { EntitySnapshot, SnapshotMessage } from '../net/protocol.ts';
/** How far in the past we render, so there is always a newer snapshot to interpolate towards. */
export const DEFAULT_INTERPOLATION_DELAY_MS = 100;
const MAX_BUFFERED_SNAPSHOTS = 32;
interface BufferedSnapshot {
readonly tick: number;
readonly receivedAt: number;
readonly entities: readonly EntitySnapshot[];
}
/**
* Keeps the last few snapshots and samples them slightly in the past, blending the two
* that straddle the render time. That is what turns 20 discrete server ticks into smooth
* motion at display refresh rate.
*/
export class SnapshotBuffer {
private readonly snapshots: BufferedSnapshot[] = [];
constructor(private readonly delayMs: number = DEFAULT_INTERPOLATION_DELAY_MS) {}
get latestTick(): number {
return this.snapshots.at(-1)?.tick ?? 0;
}
get size(): number {
return this.snapshots.length;
}
push(message: SnapshotMessage, receivedAt: number): void {
// Out-of-order frames cannot happen on a WebSocket, but a reconnect resets the tick.
const previous = this.snapshots.at(-1);
if (previous !== undefined && message.tick < previous.tick) {
this.snapshots.length = 0;
}
this.snapshots.push({ tick: message.tick, receivedAt, entities: message.entities });
if (this.snapshots.length > MAX_BUFFERED_SNAPSHOTS) {
this.snapshots.splice(0, this.snapshots.length - MAX_BUFFERED_SNAPSHOTS);
}
}
/** Returns the interpolated world state for `now` (a `performance.now()` timestamp). */
sample(now: number): readonly EntitySnapshot[] {
if (this.snapshots.length === 0) {
return [];
}
if (this.snapshots.length === 1) {
return this.snapshots[0]!.entities;
}
const renderTime = now - this.delayMs;
// Newest pair whose older half is at or before the render time.
let older = this.snapshots[0]!;
let newer = this.snapshots[1]!;
for (let i = this.snapshots.length - 1; i > 0; i--) {
if (this.snapshots[i - 1]!.receivedAt <= renderTime) {
older = this.snapshots[i - 1]!;
newer = this.snapshots[i]!;
break;
}
}
const span = newer.receivedAt - older.receivedAt;
const t = span <= 0 ? 1 : clamp01((renderTime - older.receivedAt) / span);
return interpolate(older.entities, newer.entities, t);
}
clear(): void {
this.snapshots.length = 0;
}
}
function interpolate(
older: readonly EntitySnapshot[],
newer: readonly EntitySnapshot[],
t: number,
): readonly EntitySnapshot[] {
if (t >= 1) {
return newer;
}
const previousById = new Map(older.map((entity) => [entity.id, entity]));
// Entities missing from `newer` are gone; entities missing from `older` just spawned
// and are drawn at their first known position.
return newer.map((entity) => {
const previous = previousById.get(entity.id);
if (previous === undefined) {
return entity;
}
return {
...entity,
x: previous.x + (entity.x - previous.x) * t,
y: previous.y + (entity.y - previous.y) * t,
};
});
}
function clamp01(value: number): number {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
+72
View File
@@ -0,0 +1,72 @@
import { Application } from 'pixi.js';
import { GameConnection, gameSocketUrl } from './net/connection.ts';
import { Hud } from './game/hud.ts';
import { InputTracker } from './game/input.ts';
import { WorldRenderer } from './game/renderer.ts';
import { SnapshotBuffer } from './game/snapshotBuffer.ts';
import './style.css';
const BACKGROUND_COLOR = 0x10141c;
async function bootstrap(): Promise<void> {
const app = new Application();
await app.init({
background: BACKGROUND_COLOR,
resizeTo: window,
antialias: true,
autoDensity: true,
resolution: window.devicePixelRatio,
});
document.getElementById('stage')?.appendChild(app.canvas);
const hud = new Hud();
const renderer = new WorldRenderer(app);
const snapshots = new SnapshotBuffer();
const connection = new GameConnection(gameSocketUrl(), playerName(), {
onStatus: (status) => {
hud.setStatus(status);
if (status !== 'connected') {
snapshots.clear();
}
},
onWelcome: (welcome) => {
// Replication ids are per-session, so anything drawn before this point is stale.
renderer.reset();
renderer.configure(welcome.worldWidth, welcome.worldHeight, welcome.playerEntityId);
},
onSnapshot: (snapshot, receivedAt) => {
snapshots.push(snapshot, receivedAt);
hud.setTick(snapshot.tick);
hud.setEntityCount(snapshot.entities.length);
},
onLatency: (rttMs) => hud.setPing(rttMs),
});
const input = new InputTracker((buttons) => connection.sendInput(buttons));
app.ticker.add(() => renderer.draw(snapshots.sample(performance.now())));
connection.connect();
input.start();
window.addEventListener('beforeunload', () => {
input.stop();
connection.close();
});
}
/** Keeps a name across reloads; replace with a real login when one exists. */
function playerName(): string {
const stored = localStorage.getItem('hschool.playerName');
if (stored !== null) {
return stored;
}
const generated = `player-${Math.floor(Math.random() * 10000)}`;
localStorage.setItem('hschool.playerName', generated);
return generated;
}
void bootstrap();
+158
View File
@@ -0,0 +1,158 @@
import {
decodeServerMessage,
encodeHello,
encodeInput,
encodePing,
ProtocolError,
type ServerMessage,
type SnapshotMessage,
type WelcomeMessage,
} from './protocol.ts';
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'closed';
export interface ConnectionHandlers {
onStatus?(status: ConnectionStatus): void;
onWelcome?(message: WelcomeMessage): void;
onSnapshot?(message: SnapshotMessage, receivedAt: number): void;
/** Round-trip time in milliseconds. */
onLatency?(rttMs: number): void;
}
const PING_INTERVAL_MS = 2000;
const RECONNECT_MIN_MS = 500;
const RECONNECT_MAX_MS = 8000;
/**
* Owns the WebSocket: handshake, reconnect with backoff, ping/pong and outbound input.
* Rendering code only sees decoded messages.
*/
export class GameConnection {
private socket: WebSocket | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = RECONNECT_MIN_MS;
private inputSequence = 0;
private closedByUs = false;
constructor(
private readonly url: string,
private readonly playerName: string,
private readonly handlers: ConnectionHandlers = {},
) {}
connect(): void {
this.closedByUs = false;
this.handlers.onStatus?.(this.reconnectDelay === RECONNECT_MIN_MS ? 'connecting' : 'reconnecting');
const socket = new WebSocket(this.url);
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.addEventListener('open', () => {
this.reconnectDelay = RECONNECT_MIN_MS;
socket.send(encodeHello(this.playerName));
this.handlers.onStatus?.('connected');
this.startPinging();
});
socket.addEventListener('message', (event) => this.handleMessage(event));
socket.addEventListener('close', () => this.handleClose());
socket.addEventListener('error', () => socket.close());
}
/** Sends the current button mask; called at a fixed rate by the input loop. */
sendInput(buttons: number): void {
if (this.socket?.readyState !== WebSocket.OPEN) {
return;
}
this.inputSequence = (this.inputSequence + 1) >>> 0;
this.socket.send(encodeInput(this.inputSequence, buttons));
}
close(): void {
this.closedByUs = true;
this.stopTimers();
this.socket?.close();
this.socket = null;
this.handlers.onStatus?.('closed');
}
private handleMessage(event: MessageEvent): void {
if (!(event.data instanceof ArrayBuffer)) {
return;
}
let message: ServerMessage | null;
try {
message = decodeServerMessage(event.data);
} catch (error) {
if (error instanceof ProtocolError) {
console.warn('Dropping malformed frame:', error.message);
return;
}
throw error;
}
if (message === null) {
return;
}
switch (message.type) {
case 'welcome':
this.handlers.onWelcome?.(message);
break;
case 'snapshot':
this.handlers.onSnapshot?.(message, performance.now());
break;
case 'pong':
this.handlers.onLatency?.(Math.max(0, Date.now() - message.clientTimeMs));
break;
}
}
private handleClose(): void {
this.stopTimers();
this.socket = null;
if (this.closedByUs) {
return;
}
this.handlers.onStatus?.('reconnecting');
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
}
private startPinging(): void {
this.stopPinging();
this.pingTimer = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(encodePing(Date.now()));
}
}, PING_INTERVAL_MS);
}
private stopPinging(): void {
if (this.pingTimer !== null) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private stopTimers(): void {
this.stopPinging();
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
/** Builds the game socket URL from the page origin, so the Vite proxy handles it in dev. */
export function gameSocketUrl(path = '/ws/game'): string {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${scheme}//${location.host}${path}`;
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import {
decodeServerMessage,
encodeHello,
encodeInput,
encodePing,
EntityKind,
InputButtons,
MessageType,
ProtocolError,
PROTOCOL_VERSION,
} from './protocol.ts';
/**
* These byte layouts are the contract with `ProtocolCodec.cs`. If a test here has to
* change, the C# codec and `docs/protocol.md` change with it.
*/
describe('client encoders', () => {
it('writes a hello frame with version and UTF-8 name', () => {
const view = new DataView(encodeHello('ada'));
expect(view.getUint8(0)).toBe(MessageType.ClientHello);
expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
expect(view.getUint8(2)).toBe(3);
expect(view.byteLength).toBe(6);
});
it('clamps oversized names to 32 bytes', () => {
const view = new DataView(encodeHello('x'.repeat(100)));
expect(view.getUint8(2)).toBe(32);
expect(view.byteLength).toBe(35);
});
it('writes an input frame little-endian', () => {
const buttons = InputButtons.Up | InputButtons.Right;
const view = new DataView(encodeInput(0x01020304, buttons));
expect(view.getUint8(0)).toBe(MessageType.ClientInput);
expect(view.getUint32(1, true)).toBe(0x01020304);
expect(view.getUint8(5)).toBe(buttons);
});
it('writes a ping frame carrying the client clock', () => {
const view = new DataView(encodePing(1_700_000_000_123));
expect(view.getUint8(0)).toBe(MessageType.ClientPing);
expect(Number(view.getBigInt64(1, true))).toBe(1_700_000_000_123);
});
});
describe('decodeServerMessage', () => {
it('reads a welcome frame', () => {
const buffer = new ArrayBuffer(15);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerWelcome);
view.setUint8(1, PROTOCOL_VERSION);
view.setUint32(2, 42, true);
view.setUint8(6, 20);
view.setFloat32(7, 1600, true);
view.setFloat32(11, 900, true);
expect(decodeServerMessage(buffer)).toEqual({
type: 'welcome',
protocolVersion: PROTOCOL_VERSION,
playerEntityId: 42,
tickRate: 20,
worldWidth: 1600,
worldHeight: 900,
});
});
it('reads a snapshot with every entity field', () => {
const buffer = new ArrayBuffer(7 + 21);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerSnapshot);
view.setUint32(1, 1234, true);
view.setUint16(5, 1, true);
view.setUint32(7, 7, true);
view.setUint8(11, EntityKind.Player);
view.setFloat32(12, 100, true);
view.setFloat32(16, 200, true);
view.setFloat32(20, 18, true);
view.setUint32(24, 0x4cc9f0, true);
const message = decodeServerMessage(buffer);
expect(message).toEqual({
type: 'snapshot',
tick: 1234,
entities: [{ id: 7, kind: EntityKind.Player, x: 100, y: 200, radius: 18, color: 0x4cc9f0 }],
});
});
it('reads a pong frame', () => {
const buffer = new ArrayBuffer(13);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerPong);
view.setBigInt64(1, 5n, true);
view.setUint32(9, 99, true);
expect(decodeServerMessage(buffer)).toEqual({ type: 'pong', clientTimeMs: 5, serverTick: 99 });
});
it('ignores unknown message ids so new ones stay backwards compatible', () => {
const buffer = new Uint8Array([0xf0, 0x00]).buffer;
expect(decodeServerMessage(buffer)).toBeNull();
});
it('rejects a truncated frame', () => {
const buffer = new Uint8Array([MessageType.ServerWelcome, PROTOCOL_VERSION]).buffer;
expect(() => decodeServerMessage(buffer)).toThrow(ProtocolError);
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* Browser side of the binary wire format.
*
* This file is the mirror of `src/HSchool.Protocol/ProtocolCodec.cs`; the two must be
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/
export const PROTOCOL_VERSION = 1;
export const MessageType = {
ClientHello: 0x01,
ClientInput: 0x02,
ClientPing: 0x03,
ServerWelcome: 0x81,
ServerSnapshot: 0x82,
ServerPong: 0x83,
} as const;
export const InputButtons = {
None: 0,
Up: 1 << 0,
Down: 1 << 1,
Left: 1 << 2,
Right: 1 << 3,
} as const;
export const EntityKind = {
Unknown: 0,
Player: 1,
Obstacle: 2,
} as const;
export type EntityKindValue = (typeof EntityKind)[keyof typeof EntityKind];
export interface EntitySnapshot {
readonly id: number;
readonly kind: EntityKindValue;
readonly x: number;
readonly y: number;
readonly radius: number;
/** Packed 0x00RRGGBB, ready for PixiJS. */
readonly color: number;
}
export interface WelcomeMessage {
readonly type: 'welcome';
readonly protocolVersion: number;
/** Replication id of this client's own avatar. */
readonly playerEntityId: number;
readonly tickRate: number;
readonly worldWidth: number;
readonly worldHeight: number;
}
export interface SnapshotMessage {
readonly type: 'snapshot';
readonly tick: number;
readonly entities: readonly EntitySnapshot[];
}
export interface PongMessage {
readonly type: 'pong';
readonly clientTimeMs: number;
readonly serverTick: number;
}
export type ServerMessage = WelcomeMessage | SnapshotMessage | PongMessage;
/** Thrown when a frame is truncated or carries an unexpected message id. */
export class ProtocolError extends Error {}
const encoder = new TextEncoder();
export function encodeHello(playerName: string): ArrayBuffer {
const name = encoder.encode(playerName).slice(0, 32);
const buffer = new ArrayBuffer(3 + name.length);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientHello);
view.setUint8(1, PROTOCOL_VERSION);
view.setUint8(2, name.length);
new Uint8Array(buffer, 3).set(name);
return buffer;
}
export function encodeInput(sequence: number, buttons: number): ArrayBuffer {
const buffer = new ArrayBuffer(6);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientInput);
view.setUint32(1, sequence >>> 0, true);
view.setUint8(5, buttons & 0xff);
return buffer;
}
export function encodePing(clientTimeMs: number): ArrayBuffer {
const buffer = new ArrayBuffer(9);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientPing);
view.setBigInt64(1, BigInt(Math.trunc(clientTimeMs)), true);
return buffer;
}
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
if (data.byteLength === 0) {
throw new ProtocolError('Empty frame.');
}
const view = new DataView(data);
const messageType = view.getUint8(0);
switch (messageType) {
case MessageType.ServerWelcome:
return decodeWelcome(view);
case MessageType.ServerSnapshot:
return decodeSnapshot(view);
case MessageType.ServerPong:
return decodePong(view);
default:
return null;
}
}
function decodeWelcome(view: DataView): WelcomeMessage {
ensure(view, 15);
return {
type: 'welcome',
protocolVersion: view.getUint8(1),
playerEntityId: view.getUint32(2, true),
tickRate: view.getUint8(6),
worldWidth: view.getFloat32(7, true),
worldHeight: view.getFloat32(11, true),
};
}
function decodeSnapshot(view: DataView): SnapshotMessage {
ensure(view, 7);
const tick = view.getUint32(1, true);
const count = view.getUint16(5, true);
const entitySize = 21;
ensure(view, 7 + count * entitySize);
const entities: EntitySnapshot[] = new Array(count);
let offset = 7;
for (let i = 0; i < count; i++) {
entities[i] = {
id: view.getUint32(offset, true),
kind: view.getUint8(offset + 4) as EntityKindValue,
x: view.getFloat32(offset + 5, true),
y: view.getFloat32(offset + 9, true),
radius: view.getFloat32(offset + 13, true),
color: view.getUint32(offset + 17, true),
};
offset += entitySize;
}
return { type: 'snapshot', tick, entities };
}
function decodePong(view: DataView): PongMessage {
ensure(view, 13);
return {
type: 'pong',
clientTimeMs: Number(view.getBigInt64(1, true)),
serverTick: view.getUint32(9, true),
};
}
function ensure(view: DataView, bytes: number): void {
if (view.byteLength < bytes) {
throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
}
}
+34
View File
@@ -0,0 +1,34 @@
:root {
color-scheme: dark;
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
background: #10141c;
color: #d7e0ef;
}
#stage canvas {
display: block;
}
#hud {
position: fixed;
top: 12px;
left: 12px;
display: flex;
gap: 16px;
padding: 8px 14px;
border: 1px solid #2a3242;
border-radius: 8px;
background: rgba(16, 20, 28, 0.72);
font-size: 13px;
letter-spacing: 0.02em;
pointer-events: none;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
// Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference,
// so the dev server proxies to whatever port the backend actually got.
const backend = process.env.SERVER_HTTPS ?? process.env.SERVER_HTTP ?? 'http://localhost:5180';
export default defineConfig({
server: {
proxy: {
'/api': {
target: backend,
changeOrigin: true,
secure: false,
},
'/ws': {
target: backend,
changeOrigin: true,
secure: false,
ws: true,
},
},
},
build: {
target: 'es2022',
sourcemap: true,
},
});
+9
View File
@@ -0,0 +1,9 @@
namespace HSchool.Protocol;
/// <summary>Tells the renderer which visual to use for a snapshot entity.</summary>
public enum EntityKind : byte
{
Unknown = 0,
Player = 1,
Obstacle = 2,
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Protocol</RootNamespace>
</PropertyGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
namespace HSchool.Protocol;
/// <summary>Bitmask of movement intents sent by the client each input frame.</summary>
[Flags]
public enum InputButtons : byte
{
None = 0,
Up = 1 << 0,
Down = 1 << 1,
Left = 1 << 2,
Right = 1 << 3,
}
+18
View File
@@ -0,0 +1,18 @@
namespace HSchool.Protocol;
/// <summary>
/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
/// </summary>
public enum MessageType : byte
{
None = 0x00,
ClientHello = 0x01,
ClientInput = 0x02,
ClientPing = 0x03,
ServerWelcome = 0x81,
ServerSnapshot = 0x82,
ServerPong = 0x83,
}
+37
View File
@@ -0,0 +1,37 @@
namespace HSchool.Protocol;
/// <summary>First frame from the client: protocol version handshake plus display name.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
/// <summary>
/// Movement intent for one client frame. <paramref name="Sequence"/> is echoed back
/// in future snapshots once client-side prediction lands.
/// </summary>
public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
public readonly record struct ClientPingMessage(long ClientTimeMs);
/// <summary>
/// Sent once per connection, before the first snapshot.
/// <paramref name="PlayerEntityId"/> is the replication id of this client's own avatar,
/// so the renderer can tell it apart from everyone else.
/// </summary>
public readonly record struct ServerWelcomeMessage(
byte ProtocolVersion,
uint PlayerEntityId,
byte TickRate,
float WorldWidth,
float WorldHeight);
/// <summary>One entity inside a snapshot. Kept flat and blittable on purpose.</summary>
public readonly record struct EntitySnapshot(
uint Id,
EntityKind Kind,
float X,
float Y,
float Radius,
uint Color);
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
+75
View File
@@ -0,0 +1,75 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
/// <summary>Little-endian cursor over a received frame. Mirror of <see cref="PacketWriter"/>.</summary>
public ref struct PacketReader(ReadOnlySpan<byte> buffer)
{
private readonly ReadOnlySpan<byte> _buffer = buffer;
private int _position = 0;
public readonly int Position => _position;
public readonly int Remaining => _buffer.Length - _position;
public byte ReadByte()
{
EnsureAvailable(sizeof(byte));
var value = _buffer[_position];
_position += sizeof(byte);
return value;
}
public MessageType ReadMessageType() => (MessageType)ReadByte();
public ushort ReadUInt16()
{
EnsureAvailable(sizeof(ushort));
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
_position += sizeof(ushort);
return value;
}
public uint ReadUInt32()
{
EnsureAvailable(sizeof(uint));
var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
_position += sizeof(uint);
return value;
}
public long ReadInt64()
{
EnsureAvailable(sizeof(long));
var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
_position += sizeof(long);
return value;
}
public float ReadSingle()
{
EnsureAvailable(sizeof(float));
var value = BinaryPrimitives.ReadSingleLittleEndian(_buffer[_position..]);
_position += sizeof(float);
return value;
}
public string ReadShortString()
{
var byteCount = ReadByte();
EnsureAvailable(byteCount);
var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
_position += byteCount;
return value;
}
private readonly void EnsureAvailable(int bytes)
{
if (_position + bytes > _buffer.Length)
{
throw new ProtocolException(
$"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
}
}
}
+80
View File
@@ -0,0 +1,80 @@
using System.Buffers.Binary;
using System.Text;
namespace HSchool.Protocol;
/// <summary>
/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
/// browser's <c>DataView</c> calls in <c>src/HSchool.Client/src/net/protocol.ts</c>.
/// </summary>
public ref struct PacketWriter(Span<byte> buffer)
{
private readonly Span<byte> _buffer = buffer;
private int _position = 0;
public readonly int Position => _position;
public readonly ReadOnlySpan<byte> Written => _buffer[.._position];
public void WriteByte(byte value)
{
EnsureRoom(sizeof(byte));
_buffer[_position] = value;
_position += sizeof(byte);
}
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
public void WriteUInt16(ushort value)
{
EnsureRoom(sizeof(ushort));
BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
_position += sizeof(ushort);
}
public void WriteUInt32(uint value)
{
EnsureRoom(sizeof(uint));
BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
_position += sizeof(uint);
}
public void WriteInt64(long value)
{
EnsureRoom(sizeof(long));
BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
_position += sizeof(long);
}
public void WriteSingle(float value)
{
EnsureRoom(sizeof(float));
BinaryPrimitives.WriteSingleLittleEndian(_buffer[_position..], value);
_position += sizeof(float);
}
/// <summary>Writes a UTF-8 string prefixed with a single length byte.</summary>
public void WriteShortString(string value)
{
var byteCount = Encoding.UTF8.GetByteCount(value);
if (byteCount > ProtocolConstants.MaxPlayerNameBytes)
{
throw new ProtocolException(
$"String is {byteCount} bytes, limit is {ProtocolConstants.MaxPlayerNameBytes}.");
}
WriteByte((byte)byteCount);
EnsureRoom(byteCount);
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
_position += byteCount;
}
private readonly void EnsureRoom(int bytes)
{
if (_position + bytes > _buffer.Length)
{
throw new ProtocolException(
$"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
}
}
}
+171
View File
@@ -0,0 +1,171 @@
namespace HSchool.Protocol;
/// <summary>
/// The single place where the wire format is defined on the .NET side.
/// Every change here must be mirrored in <c>src/HSchool.Client/src/net/protocol.ts</c>
/// and documented in <c>docs/protocol.md</c>.
/// </summary>
public static class ProtocolCodec
{
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientHello);
writer.WriteByte(message.ProtocolVersion);
writer.WriteShortString(message.PlayerName);
return writer.Position;
}
public static int WriteInput(Span<byte> destination, in ClientInputMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientInput);
writer.WriteUInt32(message.Sequence);
writer.WriteByte((byte)message.Buttons);
return writer.Position;
}
public static int WritePing(Span<byte> destination, in ClientPingMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientPing);
writer.WriteInt64(message.ClientTimeMs);
return writer.Position;
}
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerWelcome);
writer.WriteByte(message.ProtocolVersion);
writer.WriteUInt32(message.PlayerEntityId);
writer.WriteByte(message.TickRate);
writer.WriteSingle(message.WorldWidth);
writer.WriteSingle(message.WorldHeight);
return writer.Position;
}
public static int WritePong(Span<byte> destination, in ServerPongMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerPong);
writer.WriteInt64(message.ClientTimeMs);
writer.WriteUInt32(message.ServerTick);
return writer.Position;
}
/// <summary>Writes a full-state snapshot; entities missing from it are despawned by the client.</summary>
public static int WriteSnapshot(Span<byte> destination, uint tick, ReadOnlySpan<EntitySnapshot> entities)
{
if (entities.Length > ushort.MaxValue)
{
throw new ProtocolException($"Snapshot holds {entities.Length} entities, limit is {ushort.MaxValue}.");
}
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerSnapshot);
writer.WriteUInt32(tick);
writer.WriteUInt16((ushort)entities.Length);
foreach (var entity in entities)
{
writer.WriteUInt32(entity.Id);
writer.WriteByte((byte)entity.Kind);
writer.WriteSingle(entity.X);
writer.WriteSingle(entity.Y);
writer.WriteSingle(entity.Radius);
writer.WriteUInt32(entity.Color);
}
return writer.Position;
}
/// <summary>Exact byte size of a snapshot frame for <paramref name="entityCount"/> entities.</summary>
public static int SnapshotSize(int entityCount) =>
ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
public static ClientHelloMessage ReadHello(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientHello);
var version = reader.ReadByte();
var name = reader.ReadShortString();
return new ClientHelloMessage(version, name);
}
public static ClientInputMessage ReadInput(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientInput);
var sequence = reader.ReadUInt32();
var buttons = (InputButtons)reader.ReadByte();
return new ClientInputMessage(sequence, buttons);
}
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientPing);
return new ClientPingMessage(reader.ReadInt64());
}
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerWelcome);
var version = reader.ReadByte();
var playerEntityId = reader.ReadUInt32();
var tickRate = reader.ReadByte();
var width = reader.ReadSingle();
var height = reader.ReadSingle();
return new ServerWelcomeMessage(version, playerEntityId, tickRate, width, height);
}
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerPong);
var clientTime = reader.ReadInt64();
var serverTick = reader.ReadUInt32();
return new ServerPongMessage(clientTime, serverTick);
}
/// <summary>Reads a snapshot into <paramref name="destination"/> and returns the entity count.</summary>
public static int ReadSnapshot(ReadOnlySpan<byte> source, Span<EntitySnapshot> destination, out uint tick)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSnapshot);
tick = reader.ReadUInt32();
var count = reader.ReadUInt16();
if (count > destination.Length)
{
throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}.");
}
for (var i = 0; i < count; i++)
{
destination[i] = new EntitySnapshot(
reader.ReadUInt32(),
(EntityKind)reader.ReadByte(),
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadSingle(),
reader.ReadUInt32());
}
return count;
}
private static void Expect(ref PacketReader reader, MessageType expected)
{
var actual = reader.ReadMessageType();
if (actual != expected)
{
throw new ProtocolException($"Expected {expected} (0x{(byte)expected:X2}) but got 0x{(byte)actual:X2}.");
}
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace HSchool.Protocol;
/// <summary>Wire-format constants shared by the server and the browser client.</summary>
public static class ProtocolConstants
{
/// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 1;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 64 * 1024;
/// <summary>Bytes of a single entity inside a snapshot payload.</summary>
public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
/// <summary>Bytes of the snapshot header: message type + tick + entity count.</summary>
public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
/// <summary>Maximum UTF-8 byte length of a player name.</summary>
public const int MaxPlayerNameBytes = 32;
}
@@ -0,0 +1,4 @@
namespace HSchool.Protocol;
/// <summary>Thrown when a frame is truncated, oversized or otherwise unreadable.</summary>
public sealed class ProtocolException(string message) : Exception(message);
+20
View File
@@ -0,0 +1,20 @@
using HSchool.Protocol;
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a connection thread to the loop thread. The simulation is
/// single-threaded, so every mutation arrives as one of these.
/// </summary>
internal abstract record GameCommand
{
/// <summary>
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
/// with the replication id so the handler can send a Welcome frame.
/// </summary>
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
internal sealed record Leave(uint PlayerId) : GameCommand;
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
}
@@ -0,0 +1,13 @@
using System.Collections.Concurrent;
namespace HSchool.Server.Game;
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
internal sealed class GameCommandQueue
{
private readonly ConcurrentQueue<GameCommand> _commands = new();
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
}
+153
View File
@@ -0,0 +1,153 @@
using System.Diagnostics;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
/// drain commands, step the simulation, broadcast a full snapshot.
/// The world is touched from this thread only.
/// </summary>
internal sealed class GameLoopService(
IOptions<SimulationOptions> options,
GameCommandQueue commands,
ClientRegistry clients,
GameMetrics metrics,
ILogger<GameLoopService> logger) : BackgroundService
{
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
private const int MaxCatchUpSteps = 5;
private readonly SimulationOptions _options = options.Value;
private readonly List<EntitySnapshot> _snapshotBuffer = [];
private readonly GameWorld _world = new(options.Value);
private uint _currentTick;
private int _playerCount;
public uint CurrentTick => Volatile.Read(ref _currentTick);
public int PlayerCount => Volatile.Read(ref _playerCount);
public SimulationOptions Options => _options;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
_options.TickRate,
_options.WorldWidth,
_options.WorldHeight);
using var timer = new PeriodicTimer(_options.TickInterval);
var fixedDelta = _options.FixedDeltaTime;
var lastTimestamp = Stopwatch.GetTimestamp();
var accumulator = 0d;
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
var now = Stopwatch.GetTimestamp();
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
lastTimestamp = now;
DrainCommands();
var steps = 0;
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
{
var stepStarted = Stopwatch.GetTimestamp();
_world.Tick();
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
accumulator -= fixedDelta;
steps++;
}
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
{
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
accumulator = 0d;
}
if (steps > 0)
{
Volatile.Write(ref _currentTick, _world.CurrentTick);
BroadcastSnapshot();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
_world.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
}
}
private void DrainCommands()
{
while (commands.TryDequeue(out var command))
{
switch (command)
{
case GameCommand.Join join:
HandleJoin(join);
break;
case GameCommand.Leave leave:
_world.DespawnPlayer(leave.PlayerId);
Volatile.Write(ref _playerCount, _world.PlayerCount);
metrics.PlayerLeft();
logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
break;
case GameCommand.Input input:
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
break;
}
}
}
private void HandleJoin(GameCommand.Join join)
{
try
{
var entityId = _world.SpawnPlayer(join.PlayerId);
Volatile.Write(ref _playerCount, _world.PlayerCount);
metrics.PlayerJoined();
join.EntityId.TrySetResult(entityId);
logger.LogInformation(
"Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
join.PlayerId,
entityId,
_world.PlayerCount);
}
catch (Exception ex)
{
join.EntityId.TrySetException(ex);
}
}
private void BroadcastSnapshot()
{
_world.CaptureSnapshot(_snapshotBuffer);
// One immutable buffer is shared by every recipient, so nothing has to be copied per client.
var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
var recipients = clients.Broadcast(frame.AsMemory(0, written));
if (recipients > 0)
{
metrics.SnapshotSent(written, recipients);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Diagnostics.Metrics;
namespace HSchool.Server.Game;
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
internal sealed class GameMetrics : IDisposable
{
public const string MeterName = "HSchool.Server.Game";
private readonly Meter _meter;
private readonly Counter<long> _ticks;
private readonly Histogram<double> _tickDuration;
private readonly UpDownCounter<long> _connectedPlayers;
private readonly Counter<long> _snapshotBytes;
public GameMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
}
public void RecordTick(double durationMs)
{
_ticks.Add(1);
_tickDuration.Record(durationMs);
}
public void PlayerJoined() => _connectedPlayers.Add(1);
public void PlayerLeft() => _connectedPlayers.Add(-1);
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
public void Dispose() => _meter.Dispose();
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>HSchool.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
</Project>
+42
View File
@@ -0,0 +1,42 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace HSchool.Server.Net;
/// <summary>Tracks live connections and hands out player ids.</summary>
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
public GameClient Add(WebSocket socket)
{
var playerId = Interlocked.Increment(ref _nextPlayerId);
var client = new GameClient(playerId, socket);
_clients[playerId] = client;
return client;
}
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
/// <summary>
/// Queues the same frame for every client that finished its handshake; the buffer must not
/// be reused afterwards.
/// </summary>
public int Broadcast(ReadOnlyMemory<byte> frame)
{
var recipients = 0;
foreach (var client in _clients.Values)
{
if (client.IsReady && client.TrySend(frame))
{
recipients++;
}
}
return recipients;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Net.WebSockets;
using System.Threading.Channels;
namespace HSchool.Server.Net;
/// <summary>
/// One connected browser. Frames are queued instead of written inline so a slow client
/// can never stall the game loop; when the outbox overflows the oldest snapshot is dropped,
/// which is exactly what you want for state that is resent 20 times a second.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
private const int OutboxCapacity = 32;
private readonly Channel<ReadOnlyMemory<byte>> _outbox =
Channel.CreateBounded<ReadOnlyMemory<byte>>(new BoundedChannelOptions(OutboxCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
private bool _ready;
public uint PlayerId { get; } = playerId;
public WebSocket Socket { get; } = socket;
public string Name { get; set; } = $"player-{playerId}";
/// <summary>
/// Set once the welcome frame is out. Snapshots are only queued for ready clients, so a
/// connection never sees world state before it knows its own entity id.
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary>
public async Task RunSendLoopAsync(CancellationToken cancellationToken)
{
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
if (Socket.State != WebSocketState.Open)
{
break;
}
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
}
public void CompleteOutbox() => _outbox.Writer.TryComplete();
}
+257
View File
@@ -0,0 +1,257 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Text;
using HSchool.Protocol;
using HSchool.Server.Game;
namespace HSchool.Server.Net;
/// <summary>
/// Drives one WebSocket connection: handshake, join, then the receive loop.
/// Everything it learns from the wire is untrusted, so frames are validated before
/// they reach the simulation.
/// </summary>
internal sealed class GameSocketHandler(
ClientRegistry clients,
GameCommandQueue commands,
GameLoopService loop,
ILogger<GameSocketHandler> logger)
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken)
{
var client = clients.Add(socket);
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task? sendLoop = null;
var joined = false;
try
{
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
handshakeCts.CancelAfter(HandshakeTimeout);
var helloLength = await ReceiveFrameAsync(socket, buffer, handshakeCts.Token).ConfigureAwait(false);
if (helloLength <= 0)
{
return;
}
var hello = ProtocolCodec.ReadHello(buffer.AsSpan(0, helloLength));
if (hello.ProtocolVersion != ProtocolConstants.Version)
{
logger.LogWarning(
"Rejecting client {PlayerId}: protocol v{ClientVersion}, server speaks v{ServerVersion}.",
client.PlayerId,
hello.ProtocolVersion,
ProtocolConstants.Version);
await CloseAsync(
socket,
WebSocketCloseStatus.ProtocolError,
$"Protocol v{ProtocolConstants.Version} required.",
cancellationToken).ConfigureAwait(false);
return;
}
client.Name = SanitizeName(hello.PlayerName, client.PlayerId);
var join = new GameCommand.Join(
client.PlayerId,
new TaskCompletionSource<uint>(TaskCreationOptions.RunContinuationsAsynchronously));
commands.Enqueue(join);
var entityId = await join.EntityId.Task
.WaitAsync(HandshakeTimeout, connectionCts.Token)
.ConfigureAwait(false);
joined = true;
await SendWelcomeAsync(socket, entityId, connectionCts.Token).ConfigureAwait(false);
client.MarkReady();
// From here on every outbound frame goes through the outbox, so there is
// exactly one writer on the socket.
sendLoop = client.RunSendLoopAsync(connectionCts.Token);
await ReceiveLoopAsync(client, buffer, connectionCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Client went away or the host is shutting down.
}
catch (ProtocolException ex)
{
logger.LogWarning(ex, "Malformed frame from client {PlayerId}.", client.PlayerId);
await CloseAsync(socket, WebSocketCloseStatus.InvalidPayloadData, "Malformed frame.", CancellationToken.None)
.ConfigureAwait(false);
}
catch (WebSocketException ex)
{
logger.LogDebug(ex, "Connection {PlayerId} dropped.", client.PlayerId);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
clients.Remove(client.PlayerId);
client.CompleteOutbox();
if (joined)
{
commands.Enqueue(new GameCommand.Leave(client.PlayerId));
}
if (sendLoop is not null)
{
try
{
await sendLoop.ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
{
// Expected while tearing the connection down.
}
}
await connectionCts.CancelAsync().ConfigureAwait(false);
}
}
private async Task ReceiveLoopAsync(GameClient client, byte[] buffer, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var length = await ReceiveFrameAsync(client.Socket, buffer, cancellationToken).ConfigureAwait(false);
if (length <= 0)
{
return;
}
var frame = buffer.AsSpan(0, length);
switch (ProtocolCodec.PeekMessageType(frame))
{
case MessageType.ClientInput:
var input = ProtocolCodec.ReadInput(frame);
commands.Enqueue(new GameCommand.Input(client.PlayerId, input.Buttons, input.Sequence));
break;
case MessageType.ClientPing:
var ping = ProtocolCodec.ReadPing(frame);
SendPong(client, ping.ClientTimeMs);
break;
default:
logger.LogDebug(
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
frame[0],
client.PlayerId);
break;
}
}
}
/// <summary>Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame.</summary>
private async Task<int> ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken)
{
var offset = 0;
while (true)
{
var result = await socket
.ReceiveAsync(new ArraySegment<byte>(buffer, offset, buffer.Length - offset), cancellationToken)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
return 0;
}
if (result.MessageType != WebSocketMessageType.Binary)
{
logger.LogDebug("Dropping non-binary frame.");
return -1;
}
offset += result.Count;
if (result.EndOfMessage)
{
return offset;
}
if (offset >= buffer.Length)
{
logger.LogWarning("Frame exceeds {Limit} bytes; closing.", ProtocolConstants.MaxMessageSize);
await CloseAsync(socket, WebSocketCloseStatus.MessageTooBig, "Frame too large.", cancellationToken)
.ConfigureAwait(false);
return -1;
}
}
}
private async Task SendWelcomeAsync(WebSocket socket, uint entityId, CancellationToken cancellationToken)
{
var options = loop.Options;
var welcome = new ServerWelcomeMessage(
ProtocolConstants.Version,
entityId,
(byte)options.TickRate,
options.WorldWidth,
options.WorldHeight);
var frame = new byte[32];
var length = ProtocolCodec.WriteWelcome(frame, welcome);
await socket
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
private void SendPong(GameClient client, long clientTimeMs)
{
var frame = new byte[16];
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
client.TrySend(frame.AsMemory(0, length));
}
private static async Task CloseAsync(
WebSocket socket,
WebSocketCloseStatus status,
string description,
CancellationToken cancellationToken)
{
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
try
{
await socket.CloseAsync(status, description, cancellationToken).ConfigureAwait(false);
}
catch (WebSocketException)
{
// The peer may already be gone; nothing left to do.
}
}
}
/// <summary>Names come from the wire: strip control characters and clamp the length.</summary>
private static string SanitizeName(string name, uint playerId)
{
var trimmed = name.Trim();
if (trimmed.Length == 0)
{
return $"player-{playerId}";
}
var builder = new StringBuilder(trimmed.Length);
foreach (var character in trimmed)
{
builder.Append(char.IsControl(character) ? ' ' : character);
}
var sanitized = builder.ToString();
return sanitized.Length <= ProtocolConstants.MaxPlayerNameBytes
? sanitized
: sanitized[..ProtocolConstants.MaxPlayerNameBytes];
}
}
+88
View File
@@ -0,0 +1,88 @@
using System.Net.WebSockets;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services
.AddOptions<SimulationOptions>()
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
.Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
builder.Services.AddSingleton<ClientRegistry>();
builder.Services.AddSingleton<GameMetrics>();
builder.Services.AddSingleton<GameSocketHandler>();
builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
var api = app.MapGroup("/api");
api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
{
var options = loop.Options;
return new GameStatusResponse(
loop.CurrentTick,
options.TickRate,
loop.PlayerCount,
clients.Count,
options.WorldWidth,
options.WorldHeight);
})
.WithName("GetGameStatus");
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade.");
return;
}
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
await handler.HandleAsync(socket, context.RequestAborted);
});
app.MapDefaultEndpoints();
// In a published container the built client lands in wwwroot next to the server.
app.UseFileServer();
app.Run();
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(
uint Tick,
int TickRate,
int Players,
int Connections,
float WorldWidth,
float WorldHeight);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program;
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7180;http://localhost:5180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"HSchool.Server.Game": "Information"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"WorldWidth": 1600,
"WorldHeight": 900,
"PlayerSpeed": 260,
"PlayerRadius": 18
}
}
+107
View File
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
/// <summary>
/// Common Aspire wiring: service discovery, resilience, health checks and OpenTelemetry.
/// Referenced by every service project in the solution.
/// See https://aka.ms/dotnet/aspire/service-defaults.
/// </summary>
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(options =>
// Health probes would drown out the game traffic.
options.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath))
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Exposing health endpoints outside development has security implications:
// https://aka.ms/dotnet/aspire/healthchecks
if (app.Environment.IsDevelopment())
{
app.MapHealthChecks(HealthEndpointPath);
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = registration => registration.Tags.Contains("live"),
});
}
return app;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.ServiceDefaults</RootNamespace>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
namespace HSchool.Simulation.Components;
/// <summary>
/// Stable replication id. Arch entity ids are recycled, so the client gets this
/// monotonically increasing value instead.
/// </summary>
public struct NetworkId
{
public uint Value;
public NetworkId(uint value) => Value = value;
}
@@ -0,0 +1,19 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Components;
/// <summary>Marks an entity as driven by a connected client's input.</summary>
public struct PlayerControl
{
/// <summary>Network id of the owning connection.</summary>
public uint PlayerId;
/// <summary>Latest intent received from that connection.</summary>
public InputButtons Buttons;
/// <summary>Sequence number of that intent; reserved for prediction/reconciliation.</summary>
public uint LastInputSequence;
/// <summary>Movement speed in units per second.</summary>
public float Speed;
}
@@ -0,0 +1,14 @@
namespace HSchool.Simulation.Components;
/// <summary>World-space position in simulation units.</summary>
public struct Position
{
public float X;
public float Y;
public Position(float x, float y)
{
X = x;
Y = y;
}
}
@@ -0,0 +1,13 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Components;
/// <summary>Everything the client needs to draw the entity; replicated verbatim in snapshots.</summary>
public struct Renderable
{
public EntityKind Kind;
public float Radius;
/// <summary>Packed 0x00RRGGBB.</summary>
public uint Color;
}
@@ -0,0 +1,14 @@
namespace HSchool.Simulation.Components;
/// <summary>Simulation units per second, integrated by <c>MovementSystem</c>.</summary>
public struct Velocity
{
public float X;
public float Y;
public Velocity(float x, float y)
{
X = x;
Y = y;
}
}
+199
View File
@@ -0,0 +1,199 @@
using Arch.Core;
using HSchool.Protocol;
using HSchool.Simulation.Components;
using HSchool.Simulation.Systems;
namespace HSchool.Simulation;
/// <summary>
/// The authoritative world: an Arch <see cref="World"/> plus the fixed-step system pipeline.
/// Not thread-safe by design — only the game loop thread may touch it, everything else
/// goes through the command queue in the server layer.
/// </summary>
public sealed class GameWorld : IDisposable
{
private readonly World _world;
private readonly ISimulationSystem[] _systems;
private readonly Dictionary<uint, Entity> _playerEntities = [];
private uint _nextNetworkId = 1;
private bool _disposed;
public GameWorld(SimulationOptions? options = null)
{
Options = options ?? new SimulationOptions();
_world = World.Create();
_systems =
[
new PlayerInputSystem(),
new MovementSystem(),
new WorldBoundsSystem(),
];
SpawnObstacles();
}
public SimulationOptions Options { get; }
/// <summary>Number of fixed steps simulated so far.</summary>
public uint CurrentTick { get; private set; }
public int PlayerCount => _playerEntities.Count;
public int EntityCount => _world.CountEntities(new QueryDescription().WithAll<NetworkId>());
/// <summary>Adds a player body. Returns its replication id.</summary>
public uint SpawnPlayer(uint playerId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_playerEntities.ContainsKey(playerId))
{
throw new InvalidOperationException($"Player {playerId} is already spawned.");
}
var networkId = _nextNetworkId++;
var (x, y) = SpawnPoint(playerId);
var entity = _world.Create(
new NetworkId(networkId),
new Position(x, y),
new Velocity(0f, 0f),
new PlayerControl
{
PlayerId = playerId,
Buttons = InputButtons.None,
Speed = Options.PlayerSpeed,
},
new Renderable
{
Kind = EntityKind.Player,
Radius = Options.PlayerRadius,
Color = Palette.ForPlayer(playerId),
});
_playerEntities[playerId] = entity;
return networkId;
}
public void DespawnPlayer(uint playerId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_playerEntities.Remove(playerId, out var entity) && _world.IsAlive(entity))
{
_world.Destroy(entity);
}
}
/// <summary>Stores the latest intent for a player; applied on the next tick.</summary>
public void ApplyInput(uint playerId, InputButtons buttons, uint sequence)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_playerEntities.TryGetValue(playerId, out var entity) || !_world.IsAlive(entity))
{
return;
}
ref var control = ref _world.Get<PlayerControl>(entity);
// Late/duplicate packets carry a stale sequence; the newest intent wins.
if (sequence < control.LastInputSequence)
{
return;
}
control.Buttons = buttons;
control.LastInputSequence = sequence;
}
/// <summary>Runs one fixed step of the pipeline.</summary>
public void Tick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CurrentTick++;
var context = new SimulationContext(CurrentTick, Options.FixedDeltaTime, Options);
foreach (var system in _systems)
{
system.Update(_world, in context);
}
}
/// <summary>Fills <paramref name="buffer"/> with the replicated state of every visible entity.</summary>
public void CaptureSnapshot(List<EntitySnapshot> buffer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(buffer);
buffer.Clear();
var query = new QueryDescription().WithAll<NetworkId, Position, Renderable>();
_world.Query(in query, (ref NetworkId id, ref Position position, ref Renderable renderable) =>
{
buffer.Add(new EntitySnapshot(
id.Value,
renderable.Kind,
position.X,
position.Y,
renderable.Radius,
renderable.Color));
});
}
/// <summary>Replication id of a connected player, or <c>null</c> if it is not spawned.</summary>
public uint? GetNetworkId(uint playerId) =>
_playerEntities.TryGetValue(playerId, out var entity) && _world.IsAlive(entity)
? _world.Get<NetworkId>(entity).Value
: null;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
World.Destroy(_world);
}
/// <summary>A few static blocks so an empty world still shows something on screen.</summary>
private void SpawnObstacles()
{
ReadOnlySpan<(float X, float Y, float Radius)> layout =
[
(0.5f, 0.5f, 70f),
(0.2f, 0.25f, 45f),
(0.8f, 0.75f, 45f),
];
foreach (var (relativeX, relativeY, radius) in layout)
{
_world.Create(
new NetworkId(_nextNetworkId++),
new Position(Options.WorldWidth * relativeX, Options.WorldHeight * relativeY),
new Renderable
{
Kind = EntityKind.Obstacle,
Radius = radius,
Color = Palette.Obstacle,
});
}
}
/// <summary>Deterministic spread of spawn points around the centre of the field.</summary>
private (float X, float Y) SpawnPoint(uint playerId)
{
const int Slots = 8;
var slot = (int)(playerId % Slots);
var angle = slot * (2f * MathF.PI / Slots);
var radius = MathF.Min(Options.WorldWidth, Options.WorldHeight) * 0.3f;
return (
(Options.WorldWidth * 0.5f) + (MathF.Cos(angle) * radius),
(Options.WorldHeight * 0.5f) + (MathF.Sin(angle) * radius));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Simulation</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Arch" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// One stage of the fixed-step pipeline. Systems run in registration order on the
/// loop thread and must not capture per-step state.
/// </summary>
public interface ISimulationSystem
{
void Update(World world, in SimulationContext context);
}
+22
View File
@@ -0,0 +1,22 @@
namespace HSchool.Simulation;
/// <summary>Stable colours for replicated entities, packed as 0x00RRGGBB.</summary>
public static class Palette
{
public const uint Obstacle = 0x3A4553;
private static readonly uint[] PlayerColors =
[
0x4CC9F0,
0xF72585,
0x7BF1A8,
0xFFB703,
0xB388EB,
0xFF7A5C,
0x5CE1E6,
0xE9FF70,
];
/// <summary>Same player id always gets the same colour, on both server and client.</summary>
public static uint ForPlayer(uint playerId) => PlayerColors[playerId % (uint)PlayerColors.Length];
}
@@ -0,0 +1,7 @@
namespace HSchool.Simulation;
/// <summary>Per-step data handed to every system.</summary>
/// <param name="Tick">Index of the step being simulated.</param>
/// <param name="DeltaTime">Fixed step length in seconds.</param>
/// <param name="Options">Simulation tunables.</param>
public readonly record struct SimulationContext(uint Tick, float DeltaTime, SimulationOptions Options);
@@ -0,0 +1,23 @@
namespace HSchool.Simulation;
/// <summary>Tunables of the authoritative simulation. Bound from the <c>Simulation</c> config section.</summary>
public sealed class SimulationOptions
{
public const string SectionName = "Simulation";
/// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20;
public float WorldWidth { get; set; } = 1600f;
public float WorldHeight { get; set; } = 900f;
public float PlayerSpeed { get; set; } = 260f;
public float PlayerRadius { get; set; } = 18f;
/// <summary>Length of one fixed step.</summary>
public float FixedDeltaTime => 1f / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
}
@@ -0,0 +1,22 @@
using Arch.Core;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Integrates velocity into position with the fixed step.</summary>
public sealed class MovementSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<Position, Velocity>();
public void Update(World world, in SimulationContext context)
{
var deltaTime = context.DeltaTime;
world.Query(in Query, (ref Position position, ref Velocity velocity) =>
{
position.X += velocity.X * deltaTime;
position.Y += velocity.Y * deltaTime;
});
}
}
@@ -0,0 +1,37 @@
using Arch.Core;
using HSchool.Protocol;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Turns the latest button mask of every player into a velocity vector.</summary>
public sealed class PlayerInputSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<PlayerControl, Velocity>();
public void Update(World world, in SimulationContext context)
{
world.Query(in Query, (ref PlayerControl control, ref Velocity velocity) =>
{
var x = 0f;
var y = 0f;
if ((control.Buttons & InputButtons.Left) != 0) x -= 1f;
if ((control.Buttons & InputButtons.Right) != 0) x += 1f;
if ((control.Buttons & InputButtons.Up) != 0) y -= 1f;
if ((control.Buttons & InputButtons.Down) != 0) y += 1f;
// Normalize so diagonals are not faster than the cardinal directions.
if (x != 0f && y != 0f)
{
const float InverseSqrt2 = 0.70710678f;
x *= InverseSqrt2;
y *= InverseSqrt2;
}
velocity.X = x * control.Speed;
velocity.Y = y * control.Speed;
});
}
}
@@ -0,0 +1,47 @@
using Arch.Core;
using HSchool.Simulation.Components;
namespace HSchool.Simulation.Systems;
/// <summary>Keeps every body inside the play field and kills the velocity it pushed with.</summary>
public sealed class WorldBoundsSystem : ISimulationSystem
{
private static readonly QueryDescription Query =
new QueryDescription().WithAll<Position, Velocity, Renderable>();
public void Update(World world, in SimulationContext context)
{
var width = context.Options.WorldWidth;
var height = context.Options.WorldHeight;
world.Query(in Query, (ref Position position, ref Velocity velocity, ref Renderable renderable) =>
{
var minX = renderable.Radius;
var maxX = width - renderable.Radius;
var minY = renderable.Radius;
var maxY = height - renderable.Radius;
if (position.X < minX)
{
position.X = minX;
velocity.X = 0f;
}
else if (position.X > maxX)
{
position.X = maxX;
velocity.X = 0f;
}
if (position.Y < minY)
{
position.Y = minY;
velocity.Y = 0f;
}
else if (position.Y > maxY)
{
position.Y = maxY;
velocity.Y = 0f;
}
});
}
}