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
+42
View File
@@ -0,0 +1,42 @@
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{cs,csx}]
indent_size = 4
# Namespaces and usings
csharp_style_namespace_declarations = file_scoped:warning
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# Expression preferences
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = true:warning
csharp_style_prefer_primary_constructors = true:suggestion
dotnet_style_prefer_collection_expression = true:suggestion
# Naming: private fields are _camelCase
dotnet_naming_rule.private_fields_underscore.severity = warning
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_prefix.required_prefix = _
dotnet_naming_style.underscore_prefix.capitalization = camel_case
[*.{ts,js,mts,cts,json,jsonc,css,html,yml,yaml}]
indent_size = 2
[*.{csproj,props,targets,slnx}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+43
View File
@@ -0,0 +1,43 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- run: dotnet restore HSchool.slnx
- run: dotnet build HSchool.slnx --no-restore --configuration Release
# The AppHost tests run headless, so no Node install is needed here.
- run: dotnet test HSchool.slnx --no-build --configuration Release
client:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/HSchool.Client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: src/HSchool.Client/package-lock.json
- run: npm ci
- run: npm run build
- run: npm test
+4
View File
@@ -414,3 +414,7 @@ FodyWeavers.xsd
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# h-school
src/HSchool.Client/dist/
*.tsbuildinfo
+122
View File
@@ -0,0 +1,122 @@
# Working agreements
Read this before changing anything. It is written for coding agents, and it is just as valid for
humans. [`docs/architecture.md`](docs/architecture.md) explains *why* the pieces are shaped this
way; this file is *how to work in them*.
## Orientation
| I want to change… | Go to |
| --- | --- |
| game rules, movement, entities | `src/HSchool.Simulation` |
| what the client receives | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, endpoints | `src/HSchool.Server` |
| what runs locally | `src/HSchool.AppHost/AppHost.cs` |
| rendering, input, HUD | `src/HSchool.Client/src` |
## Commands
```bash
dotnet build HSchool.slnx
```
```bash
dotnet test
```
```bash
npm --prefix src/HSchool.Client test
```
```bash
npm --prefix src/HSchool.Client run build
```
```bash
dotnet run --project src/HSchool.AppHost
```
`run-aspire.cmd` is the same command for Windows users who want a double-clickable entry point —
keep the two in sync if the AppHost path ever moves.
`dotnet run --project src/HSchool.AppHost` starts the server *and* the Vite dev server and opens
the Aspire dashboard. The Vite port is assigned per run (`npm run dev -- --port <random>`), so read
the client URL off the dashboard instead of assuming 5173.
Do not start a dev server with a bare `npm run dev` when you meant to run the whole app — the
client only finds the backend through the Aspire-injected `SERVER_HTTP` environment variable, or
the `localhost:5180` fallback that matches the server's own launch profile.
## Invariants
These are the rules that keep the base coherent. Breaking one is a design decision, not a detail —
say so explicitly in the change description.
1. **The server is authoritative.** The client sends intents and draws what it is told. No game
logic in `src/HSchool.Client`.
2. **The protocol lives in three places at once.** `ProtocolCodec.cs`, `protocol.ts` and
`docs/protocol.md` change in the same commit. A layout change bumps
`ProtocolConstants.Version` / `PROTOCOL_VERSION`. Tests on both sides assert byte offsets —
if one of them has to change, so do the other two.
3. **Only the loop thread touches `GameWorld`.** Everything inbound goes through
`GameCommandQueue`; everything outbound goes through the per-client outbox. No locks around the
ECS world, no `Task.Run` into it.
4. **The simulation knows nothing about the network.** `HSchool.Simulation` must not reference
ASP.NET Core, sockets or logging infrastructure. It stays testable without a host.
5. **Fixed timestep.** Systems get `SimulationContext.DeltaTime`, never wall-clock time and never
`DateTime.Now`. Same inputs, same results — `Simulation_IsDeterministicForTheSameInputs` guards it.
6. **Everything from the wire is untrusted.** Validate lengths and ranges in the handler before
anything reaches the simulation.
## Conventions
**C#**
- File-scoped namespaces, `var` where the type is obvious, primary constructors for services.
- Private fields are `_camelCase`; `.editorconfig` enforces it.
- Nullable is on everywhere. Don't add `!` to silence it; fix the flow.
- Internal by default in `HSchool.Server`; public only where another project consumes it.
- New tunables go on `SimulationOptions` with a default, not as a constant buried in a system.
**TypeScript**
- `strict` is on, no `any`, no non-null `!` assertions.
- Relative imports carry the `.ts` extension (bundler resolution is configured for it).
- Modules stay thin: `net/` speaks protocol, `game/` renders, `main.ts` wires them together.
- No framework. If a UI need appears, plain DOM first.
**Both**
- Comments explain *why*, not *what*. Assume the reader can read code.
- Match the surrounding style rather than introducing a new one.
## Testing policy
- Simulation changes need a `GameWorld` test. They are fast, hermetic and do not need a host.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
- Server wiring, endpoints and the WebSocket handshake belong in `tests/HSchool.AppHost.Tests`.
That suite shares one AppHost across all tests (`AppHostFixture`) — keep it that way, booting
per test costs about ten seconds each.
- Never assert on the *first* snapshot after a join without checking the entity is in it; the
frame in flight may predate the spawn. Use `ReceiveSnapshotWithAsync`.
## Dependencies
- NuGet versions are centrally managed in `Directory.Packages.props`. Add the version there and a
bare `<PackageReference Include="..." />` in the project.
- Transitive pinning is on, so a downgrade warning means you bump the central version rather than
adding a per-project override.
- Keep the dependency count low. Arch, PixiJS, Aspire and the test runners are the whole budget;
anything new needs a reason in the change description.
## Things that will bite you
- `PeriodicTimer` does not catch up on its own. The accumulator in `GameLoopService` does, capped
at 5 steps — do not "simplify" it away.
- Arch recycles entity ids. Replicate `NetworkId`, never `Entity.Id`.
- Snapshots are full-state: an entity missing from a frame is *despawned* by the client. Filtering
entities out of a snapshot is how you accidentally delete them on screen.
- The client outbox drops the oldest frame under pressure. That is correct for snapshots and wrong
for anything that must arrive exactly once — such a message would need its own path.
- `erasableSyntaxOnly` is off in `tsconfig.app.json` on purpose: constructor parameter properties
are used throughout.
+6
View File
@@ -0,0 +1,6 @@
# h-school
Working agreements, commands and invariants live in @AGENTS.md — read it before changing code.
Architecture: @docs/architecture.md
Wire protocol: @docs/protocol.md
+20
View File
@@ -0,0 +1,20 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<!-- Tests opt in via <IsTestProject>; keeps `dotnet test` discovery predictable. -->
<PropertyGroup Condition="'$(IsTestProject)' == 'true'">
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup Label="ECS">
<PackageVersion Include="Arch" Version="2.1.0" />
</ItemGroup>
<ItemGroup Label="Aspire">
<PackageVersion Include="Aspire.Hosting.AppHost" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.4.6" />
</ItemGroup>
<ItemGroup Label="Runtime">
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/HSchool.AppHost/HSchool.AppHost.csproj" />
<Project Path="src/HSchool.Protocol/HSchool.Protocol.csproj" />
<Project Path="src/HSchool.Server/HSchool.Server.csproj" />
<Project Path="src/HSchool.ServiceDefaults/HSchool.ServiceDefaults.csproj" />
<Project Path="src/HSchool.Simulation/HSchool.Simulation.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/HSchool.AppHost.Tests/HSchool.AppHost.Tests.csproj" />
<Project Path="tests/HSchool.Protocol.Tests/HSchool.Protocol.Tests.csproj" />
<Project Path="tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj" />
</Folder>
</Solution>
+105
View File
@@ -1,2 +1,107 @@
# h-school # h-school
Base for a multiplayer browser game: an authoritative .NET server simulating the world with an
ECS, a PixiJS client that renders snapshots, and .NET Aspire tying them together for local runs
and integration tests.
There is no game here yet — there is a world with a few obstacles, players that can walk around
it, and every piece of plumbing needed to build a game on top.
## Stack
| Layer | Choice |
| --- | --- |
| Server | .NET 10, ASP.NET Core |
| Simulation | [Arch](https://github.com/genaray/Arch) ECS, fixed 20 Hz tick |
| Transport | raw WebSocket, custom binary protocol |
| Client | TypeScript, [PixiJS 8](https://pixijs.com/), Vite |
| Orchestration | .NET Aspire 13 |
| Tests | xUnit v3, Vitest, `Aspire.Hosting.Testing` |
## Prerequisites
- [.NET SDK 10](https://dotnet.microsoft.com/download) (`global.json` pins the 10.0.1xx band)
- [Node.js](https://nodejs.org/) 22.12 or newer
- Optional: the Aspire CLI (`dotnet tool install -g aspire.cli`) if you prefer `aspire run`
## Run everything
```bash
dotnet run --project src/HSchool.AppHost
```
On Windows `run-aspire.cmd` does the same and can be double-clicked; it checks that the .NET SDK
and Node are on PATH first and passes any arguments through
(`run-aspire.cmd --launch-profile http`).
The Aspire dashboard opens with two resources: `server` (ASP.NET Core) and `client` (Vite dev
server). Aspire assigns the client a random port on every run, so take its URL from the dashboard
rather than guessing. Open it and use **WASD** or the arrow keys to move — the HUD shows
connection state, server tick, round-trip time and entity count.
Open the same URL in a second tab to see another player: both are simulated by the one server.
## Run the pieces separately
```bash
dotnet run --project src/HSchool.Server
```
```bash
npm --prefix src/HSchool.Client run dev
```
Without Aspire the client falls back to `http://localhost:5180` for its `/api` and `/ws` proxy,
which matches the server's launch profile.
## Tests
```bash
dotnet test
```
- `tests/HSchool.Protocol.Tests` — wire-format round-trips and byte layouts.
- `tests/HSchool.Simulation.Tests` — ECS behaviour against `GameWorld`, no host involved.
- `tests/HSchool.AppHost.Tests` — boots the real Aspire graph, connects a WebSocket, plays a few
ticks. Runs headless (`--HSchool:Headless=true`), so no Node install is needed.
```bash
npm --prefix src/HSchool.Client test
```
Vitest covers the client codec and snapshot interpolation.
## Layout
```
src/
HSchool.Protocol/ binary wire format (shared contract with the client)
HSchool.Simulation/ Arch ECS world, components, systems
HSchool.Server/ ASP.NET Core host, WebSocket endpoint, game loop
HSchool.ServiceDefaults/ Aspire telemetry, health checks, resilience
HSchool.AppHost/ Aspire orchestration
HSchool.Client/ Vite + TypeScript + PixiJS renderer
tests/
docs/
architecture.md how the pieces fit together
protocol.md the wire format, byte by byte
AGENTS.md working agreements for humans and coding agents
```
## Configuration
Simulation tunables live under the `Simulation` section of
`src/HSchool.Server/appsettings.json`:
| Key | Default | Meaning |
| --- | --- | --- |
| `TickRate` | 20 | fixed simulation steps per second |
| `WorldWidth` / `WorldHeight` | 1600 × 900 | field size in simulation units |
| `PlayerSpeed` | 260 | units per second |
| `PlayerRadius` | 18 | player body radius |
## What is deliberately missing
No authentication, no persistence, no client-side prediction, no delta compression, no rooms or
matchmaking. Each of these has a natural seam described in
[`docs/architecture.md`](docs/architecture.md).
+5
View File
@@ -0,0 +1,5 @@
{
"appHost": {
"path": "src/HSchool.AppHost/HSchool.AppHost.csproj"
}
}
+97
View File
@@ -0,0 +1,97 @@
# Architecture
The server owns the world; the browser draws it. There is no game logic on the client, and there
is no rendering on the server.
```
┌───────────────────────────── Aspire AppHost ─────────────────────────────┐
│ │
│ ┌────────────────────────┐ WebSocket /ws/game ┌──────────────────┐ │
│ │ HSchool.Server │ ◄────── binary ──────► │ HSchool.Client │ │
│ │ │ │ (Vite + Pixi) │ │
│ │ GameLoopService 20 Hz │ HTTP /api, /health └──────────────────┘ │
│ │ ├── GameCommandQueue│ │
│ │ ├── GameWorld (Arch)│ │
│ │ └── ClientRegistry │ │
│ └────────────────────────┘ │
│ │ OTLP logs / traces / metrics │
│ ▼ │
│ Aspire dashboard │
└──────────────────────────────────────────────────────────────────────────┘
```
## Projects
| Project | Role |
| --- | --- |
| `src/HSchool.Protocol` | Binary wire format. No dependencies, referenced by everything that talks to the network. |
| `src/HSchool.Simulation` | Arch ECS world, components, systems, fixed-step pipeline. No ASP.NET, no sockets — this is what unit tests exercise. |
| `src/HSchool.Server` | ASP.NET Core host: WebSocket endpoint, connection lifetime, the loop that drives the simulation. |
| `src/HSchool.ServiceDefaults` | Shared Aspire wiring: OpenTelemetry, health checks, service discovery, resilience. |
| `src/HSchool.AppHost` | Aspire orchestration: which resources run and how they find each other. |
| `src/HSchool.Client` | Vite + TypeScript + PixiJS renderer. |
Dependency direction is one-way: `Protocol ← Simulation ← Server ← AppHost`. Nothing in
`Simulation` knows about HTTP, and nothing in `Protocol` knows about ECS.
## The tick
`GameLoopService` wakes on a `PeriodicTimer` at the configured rate (20 Hz by default) and, for
each wake-up:
1. **Drains the command queue.** Join, leave and input all arrive from connection threads as
`GameCommand` records. This is the only way anything mutates the world.
2. **Steps the simulation** with a fixed delta (`1 / TickRate`), catching up at most 5 steps if the
host stalled; a longer backlog is dropped with a warning rather than simulated in a burst.
3. **Captures and broadcasts a snapshot.** One immutable buffer is shared by every connection.
`GameWorld` is single-threaded on purpose: only the loop thread touches the Arch `World`.
Everything else communicates through `GameCommandQueue` (inbound) and per-client outboxes
(outbound). That is the whole concurrency model — if you find yourself wanting a lock, you are
probably about to break it.
## ECS layout
Components are plain mutable structs in `HSchool.Simulation/Components`:
- `Position`, `Velocity` — movement state.
- `PlayerControl` — the latest input mask plus its sequence number and the owner's player id.
- `Renderable` — kind, radius and colour; replicated verbatim to the client.
- `NetworkId` — stable replication id, because Arch recycles entity ids.
Systems implement `ISimulationSystem` and run in registration order:
`PlayerInputSystem` (intent → velocity) → `MovementSystem` (velocity → position) →
`WorldBoundsSystem` (clamp to the field). Adding a system means adding it to the array in
`GameWorld`'s constructor — order is explicit, not discovered.
## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a player id.
2. The client sends `Hello`; a version mismatch closes the socket.
3. The handler enqueues a `Join` command and waits for the loop thread to spawn the avatar.
4. The `Welcome` frame goes out, the client is marked ready, and only then does it start
receiving snapshots — so world state never arrives before the client knows its own entity id.
5. The receive loop turns `Input` into commands and answers `Ping` directly.
6. On disconnect the client is removed from the registry and a `Leave` command despawns the avatar.
Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client
that cannot keep up loses intermediate snapshots instead of stalling the loop.
## Rendering
The client buffers snapshots and renders ~100 ms in the past (`SnapshotBuffer`), interpolating
between the two frames that straddle the render time. That is what turns 20 discrete server ticks
into smooth motion at display refresh rate, at the cost of a fixed visual delay.
`WorldRenderer` keeps one PixiJS `Graphics` per replication id, creates it on first sight and
destroys it when the id disappears from a snapshot. The field is scaled to fit the viewport with
letterboxing, so every player sees the same area regardless of window size.
## Where to add things next
- **New replicated component**: add the struct, extend `GameWorld.CaptureSnapshot`, extend the
snapshot layout in [`protocol.md`](protocol.md) and both codecs, bump the protocol version.
- **New system**: implement `ISimulationSystem`, register it in `GameWorld`, unit-test it against
`GameWorld` directly — no server needed.
- **Client-side prediction**: the input `sequence` already travels to the server; echo the last
processed sequence back in snapshots, then replay unacknowledged inputs on the client.
+123
View File
@@ -0,0 +1,123 @@
# Wire protocol v1
Binary frames over a single WebSocket at `/ws/game`. One protocol message per frame, no
framing header beyond the message id. **All multi-byte numbers are little-endian.**
Three files must stay in sync — change them in the same commit:
| Where | File |
| --- | --- |
| Server codec | [`src/HSchool.Protocol/ProtocolCodec.cs`](../src/HSchool.Protocol/ProtocolCodec.cs) |
| Client codec | [`src/HSchool.Client/src/net/protocol.ts`](../src/HSchool.Client/src/net/protocol.ts) |
| This document | `docs/protocol.md` |
Any change to a layout below bumps `ProtocolConstants.Version` / `PROTOCOL_VERSION`. The server
closes connections whose hello carries a different version with `1002 ProtocolError`.
## Message ids
Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted
frame is obvious at a glance.
| Id | Direction | Message |
| --- | --- | --- |
| `0x01` | C → S | Hello |
| `0x02` | C → S | Input |
| `0x03` | C → S | Ping |
| `0x81` | S → C | Welcome |
| `0x82` | S → C | Snapshot |
| `0x83` | S → C | Pong |
## Client → server
### `0x01` Hello
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x01` |
| 1 | `u8` | protocol version |
| 2 | `u8` | name length in bytes (≤ 32) |
| 3 | `u8[]` | UTF-8 name |
### `0x02` Input
Sent at ~30 Hz whether or not the mask changed.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x02` |
| 1 | `u32` | sequence number, monotonically increasing |
| 5 | `u8` | button mask |
Button mask: `1` up, `2` down, `4` left, `8` right. Frames with a sequence lower than the last
accepted one are ignored, so a late packet cannot undo a newer intent.
### `0x03` Ping
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x03` |
| 1 | `i64` | client clock in milliseconds |
## Server → client
### `0x81` Welcome — 15 bytes
The first frame the client receives; no snapshot is queued before it.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x81` |
| 1 | `u8` | protocol version |
| 2 | `u32` | replication id of this client's own avatar |
| 6 | `u8` | tick rate in Hz |
| 7 | `f32` | world width |
| 11 | `f32` | world height |
### `0x82` Snapshot — 7 + 21·N bytes
Full state, no delta compression yet. **Entities missing from a snapshot are despawned by the
client**, which is why every visible entity is present in every frame.
Header:
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x82` |
| 1 | `u32` | tick |
| 5 | `u16` | entity count |
Then, per entity (21 bytes):
| Offset | Type | Field |
| --- | --- | --- |
| +0 | `u32` | replication id (never reused within a session) |
| +4 | `u8` | kind: `0` unknown, `1` player, `2` obstacle |
| +5 | `f32` | x |
| +9 | `f32` | y |
| +13 | `f32` | radius |
| +17 | `u32` | colour, packed `0x00RRGGBB` |
### `0x83` Pong — 13 bytes
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x83` |
| 1 | `i64` | client clock, echoed unchanged |
| 9 | `u32` | server tick when the ping was handled |
## Guarantees and limits
- Frames larger than 64 KiB are refused with close status `1009 MessageTooBig`.
- A malformed frame closes the connection with `1007 InvalidPayloadData`.
- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
older clients within the same protocol version.
- Snapshot delivery is lossy under back pressure: each connection buffers 32 frames and drops the
oldest, because a stale snapshot is worthless once a newer one exists.
## Not in v1 yet
Client-side prediction and reconciliation (the `sequence` field exists for it but is never echoed
back), delta compression, interest management, and any form of authentication.
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestPatch"
}
}
+80
View File
@@ -0,0 +1,80 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.AppHost", "src\HSchool.AppHost\HSchool.AppHost.csproj", "{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Protocol", "src\HSchool.Protocol\HSchool.Protocol.csproj", "{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Server", "src\HSchool.Server\HSchool.Server.csproj", "{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.ServiceDefaults", "src\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj", "{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation", "src\HSchool.Simulation\HSchool.Simulation.csproj", "{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.AppHost.Tests", "tests\HSchool.AppHost.Tests\HSchool.AppHost.Tests.csproj", "{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Protocol.Tests", "tests\HSchool.Protocol.Tests\HSchool.Protocol.Tests.csproj", "{962E7F03-8B12-5802-91AA-105EEC2060E4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Simulation.Tests", "tests\HSchool.Simulation.Tests\HSchool.Simulation.Tests.csproj", "{25518ACB-AC00-4DE7-7F61-2756A5F47A38}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6}.Release|Any CPU.Build.0 = Release|Any CPU
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C}.Release|Any CPU.Build.0 = Release|Any CPU
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82}.Release|Any CPU.Build.0 = Release|Any CPU
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9}.Release|Any CPU.Build.0 = Release|Any CPU
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC}.Release|Any CPU.Build.0 = Release|Any CPU
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465}.Release|Any CPU.Build.0 = Release|Any CPU
{962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{962E7F03-8B12-5802-91AA-105EEC2060E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{962E7F03-8B12-5802-91AA-105EEC2060E4}.Release|Any CPU.Build.0 = Release|Any CPU
{25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25518ACB-AC00-4DE7-7F61-2756A5F47A38}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C6CF7544-0A45-4E6C-BEBD-B8B2D20772F6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E57D6E25-B562-F9BB-FCDA-2C71AA526B2C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6AFF81FD-42DB-B804-09F8-AB0B20E97E82} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D5207CA6-5DD2-AF48-3D59-41C95A1F91A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{06DDC3A1-D2DD-F3E7-8FB5-0A04BBD7B6EC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5F583583-FF9A-2935-F4EF-D2CDD8DFC465} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{962E7F03-8B12-5802-91AA-105EEC2060E4} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{25518ACB-AC00-4DE7-7F61-2756A5F47A38} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
EndGlobalSection
EndGlobal
+42
View File
@@ -0,0 +1,42 @@
@echo off
setlocal
rem Starts the whole app: game server, Vite client and the Aspire dashboard.
rem Arguments are passed through, e.g. run-aspire.cmd --launch-profile http
cd /d "%~dp0"
where dotnet >nul 2>&1
if errorlevel 1 (
echo [run-aspire] dotnet SDK not found in PATH.
echo Install .NET 10: https://dotnet.microsoft.com/download
call :maybe_pause
exit /b 1
)
where node >nul 2>&1
if errorlevel 1 (
echo [run-aspire] Node.js not found in PATH - the client resource will fail to start.
echo Install Node 22.12 or newer: https://nodejs.org
echo.
)
echo [run-aspire] Starting the Aspire AppHost. Press Ctrl+C to shut everything down.
echo.
dotnet run --project "src\HSchool.AppHost\HSchool.AppHost.csproj" %*
set "EXITCODE=%ERRORLEVEL%"
if not "%EXITCODE%"=="0" (
echo.
echo [run-aspire] AppHost exited with code %EXITCODE%.
)
call :maybe_pause
endlocal & exit /b %EXITCODE%
rem Keeps the window open when the file was double-clicked from Explorer.
:maybe_pause
echo %cmdcmdline% | find /i "%~nx0" >nul
if not errorlevel 1 pause
exit /b 0
+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;
}
});
}
}
@@ -0,0 +1,51 @@
using Microsoft.Extensions.Logging;
namespace HSchool.AppHost.Tests;
/// <summary>
/// Boots the AppHost once for the whole suite — starting it per test costs about ten
/// seconds each. The client resource is skipped (<c>HSchool:Headless</c>), so the tests
/// need no Node install.
/// </summary>
public sealed class AppHostFixture : IAsyncLifetime
{
private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120);
private DistributedApplication? _app;
public DistributedApplication App =>
_app ?? throw new InvalidOperationException("The AppHost has not been started.");
public async ValueTask InitializeAsync()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.HSchool_AppHost>(["--HSchool:Headless=true"], CancellationToken.None);
appHost.Services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Warning);
logging.AddFilter("Aspire.", LogLevel.Warning);
});
_app = await appHost.BuildAsync().WaitAsync(StartupTimeout);
await _app.StartAsync().WaitAsync(StartupTimeout);
await _app.ResourceNotifications
.WaitForResourceHealthyAsync("server", CancellationToken.None)
.WaitAsync(StartupTimeout);
}
public async ValueTask DisposeAsync()
{
if (_app is not null)
{
await _app.DisposeAsync();
}
}
}
/// <summary>Groups every integration test around the single AppHost instance.</summary>
[CollectionDefinition(Name)]
public sealed class AppHostCollection : ICollectionFixture<AppHostFixture>
{
public const string Name = "apphost";
}
@@ -0,0 +1,243 @@
using System.Net.WebSockets;
using System.Text.Json;
using HSchool.Protocol;
namespace HSchool.AppHost.Tests;
/// <summary>
/// Talks to the running server exactly the way the browser client does: binary frames over
/// a WebSocket, plus the HTTP endpoints the dashboard and probes use.
/// </summary>
[Collection(AppHostCollection.Name)]
public class GameServerIntegrationTests(AppHostFixture fixture)
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
private DistributedApplication App => fixture.App;
[Fact]
public async Task HealthEndpoint_ReportsHealthy()
{
using var client = App.CreateHttpClient("server");
using var response = await client.GetAsync("/health", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task StatusEndpoint_ReportsARunningLoop()
{
using var client = App.CreateHttpClient("server");
using var response = await client.GetAsync("/api/status", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var status = JsonSerializer.Deserialize<StatusResponse>(
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken),
JsonSerializerOptions.Web);
Assert.NotNull(status);
Assert.Equal(20, status.TickRate);
Assert.True(status.WorldWidth > 0);
// The loop runs on its own thread; give it a moment to produce a tick.
await WaitUntilAsync(
async () => (await GetStatusAsync(client)).Tick > 0,
TimeSpan.FromSeconds(5));
}
[Fact]
public async Task Handshake_AnswersWithAWelcomeFrame()
{
using var socket = await ConnectAsync();
var welcome = await ReceiveWelcomeAsync(socket);
Assert.Equal(ProtocolConstants.Version, welcome.ProtocolVersion);
Assert.Equal(20, welcome.TickRate);
Assert.True(welcome.PlayerEntityId > 0);
}
[Fact]
public async Task Snapshots_ArriveAndIncludeTheJoinedPlayer()
{
using var socket = await ConnectAsync();
var welcome = await ReceiveWelcomeAsync(socket);
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
Assert.Contains(entities, entity => entity.Kind == EntityKind.Obstacle);
}
[Fact]
public async Task Input_MovesThePlayerOnTheServer()
{
using var socket = await ConnectAsync();
var welcome = await ReceiveWelcomeAsync(socket);
var first = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
var startX = first.Single(entity => entity.Id == welcome.PlayerEntityId).X;
// Hold "right" for a few ticks, draining snapshots so the socket never backs up.
var sequence = 0u;
var lastX = startX;
for (var i = 0; i < 20; i++)
{
await SendAsync(socket, buffer =>
ProtocolCodec.WriteInput(buffer, new ClientInputMessage(++sequence, InputButtons.Right)));
var entities = await ReceiveSnapshotWithAsync(socket, welcome.PlayerEntityId);
lastX = entities.Single(entity => entity.Id == welcome.PlayerEntityId).X;
}
Assert.True(lastX > startX, $"Player did not move right: {startX} -> {lastX}.");
}
[Fact]
public async Task Ping_IsAnsweredWithTheSameTimestamp()
{
using var socket = await ConnectAsync();
await ReceiveWelcomeAsync(socket);
const long ClientTime = 1_700_000_000_123;
await SendAsync(socket, buffer =>
ProtocolCodec.WritePing(buffer, new ClientPingMessage(ClientTime)));
var pong = await ReceiveUntilAsync(socket, MessageType.ServerPong);
Assert.Equal(ClientTime, ProtocolCodec.ReadPong(pong).ClientTimeMs);
}
[Fact]
public async Task VersionMismatch_IsRejected()
{
using var socket = await ConnectRawAsync();
await SendAsync(socket, buffer => ProtocolCodec.WriteHello(
buffer,
new ClientHelloMessage((byte)(ProtocolConstants.Version + 1), "stale-client")));
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken);
Assert.Equal(WebSocketMessageType.Close, result.MessageType);
Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus);
}
private async Task<ClientWebSocket> ConnectAsync(string playerName = "integration-test")
{
var socket = await ConnectRawAsync();
await SendAsync(socket, buffer =>
ProtocolCodec.WriteHello(buffer, new ClientHelloMessage(ProtocolConstants.Version, playerName)));
return socket;
}
private async Task<ClientWebSocket> ConnectRawAsync()
{
var http = App.GetEndpoint("server", "http");
var uri = new UriBuilder(http) { Scheme = "ws", Path = "/ws/game" }.Uri;
var socket = new ClientWebSocket();
await socket.ConnectAsync(uri, TestContext.Current.CancellationToken).WaitAsync(DefaultTimeout);
return socket;
}
private static async Task SendAsync(WebSocket socket, Func<byte[], int> write)
{
var buffer = new byte[64];
var length = write(buffer);
await socket.SendAsync(
buffer.AsMemory(0, length),
WebSocketMessageType.Binary,
endOfMessage: true,
TestContext.Current.CancellationToken);
}
private static async Task<ServerWelcomeMessage> ReceiveWelcomeAsync(WebSocket socket) =>
ProtocolCodec.ReadWelcome(await ReceiveUntilAsync(socket, MessageType.ServerWelcome));
private static async Task<EntitySnapshot[]> ReceiveSnapshotAsync(WebSocket socket)
{
var frame = await ReceiveUntilAsync(socket, MessageType.ServerSnapshot);
var entities = new EntitySnapshot[ushort.MaxValue];
var count = ProtocolCodec.ReadSnapshot(frame, entities, out _);
return entities[..count];
}
/// <summary>
/// Reads snapshots until the given entity shows up. The very first snapshot after a join can
/// still describe the tick before the spawn was applied.
/// </summary>
private static async Task<EntitySnapshot[]> ReceiveSnapshotWithAsync(WebSocket socket, uint entityId)
{
for (var attempt = 0; attempt < 10; attempt++)
{
var entities = await ReceiveSnapshotAsync(socket);
if (Array.Exists(entities, entity => entity.Id == entityId))
{
return entities;
}
}
throw new InvalidOperationException($"Entity {entityId} never appeared in a snapshot.");
}
/// <summary>Reads frames until one of <paramref name="expected"/> shows up.</summary>
private static async Task<byte[]> ReceiveUntilAsync(WebSocket socket, MessageType expected)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(DefaultTimeout);
var buffer = new byte[ProtocolConstants.MaxMessageSize];
while (true)
{
var result = await socket.ReceiveAsync(buffer, timeout.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
}
var frame = buffer[..result.Count];
if (ProtocolCodec.PeekMessageType(frame) == expected)
{
return frame;
}
}
}
private static async Task<StatusResponse> GetStatusAsync(HttpClient client)
{
var json = await client.GetStringAsync("/api/status", TestContext.Current.CancellationToken);
return JsonSerializer.Deserialize<StatusResponse>(json, JsonSerializerOptions.Web)!;
}
private static async Task WaitUntilAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await condition())
{
return;
}
await Task.Delay(100, TestContext.Current.CancellationToken);
}
Assert.Fail($"Condition was not met within {timeout}.");
}
private sealed record StatusResponse(
uint Tick,
int TickRate,
int Players,
int Connections,
float WorldWidth,
float WorldHeight);
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.AppHost.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.AppHost\HSchool.AppHost.csproj" />
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="System.Net" />
<Using Include="Aspire.Hosting" />
<Using Include="Aspire.Hosting.Testing" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Protocol.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.Protocol\HSchool.Protocol.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,157 @@
namespace HSchool.Protocol.Tests;
/// <summary>
/// The wire format is a contract with the browser client. Round-trips prove the C# side is
/// self-consistent; the explicit byte-layout tests are what keeps
/// <c>src/HSchool.Client/src/net/protocol.ts</c> honest.
/// </summary>
public class ProtocolCodecTests
{
[Fact]
public void Hello_RoundTrips()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, "ada");
Span<byte> buffer = stackalloc byte[64];
var length = ProtocolCodec.WriteHello(buffer, message);
Assert.Equal(message, ProtocolCodec.ReadHello(buffer[..length]));
}
[Fact]
public void Input_RoundTrips()
{
var message = new ClientInputMessage(0x01020304, InputButtons.Up | InputButtons.Right);
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, message);
Assert.Equal(6, length);
Assert.Equal(message, ProtocolCodec.ReadInput(buffer[..length]));
}
[Fact]
public void Ping_RoundTrips()
{
var message = new ClientPingMessage(1_700_000_000_123);
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WritePing(buffer, message);
Assert.Equal(9, length);
Assert.Equal(message, ProtocolCodec.ReadPing(buffer[..length]));
}
[Fact]
public void Welcome_RoundTripsAndIsFifteenBytes()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 42, 20, 1600f, 900f);
Span<byte> buffer = stackalloc byte[32];
var length = ProtocolCodec.WriteWelcome(buffer, message);
Assert.Equal(15, length);
Assert.Equal(message, ProtocolCodec.ReadWelcome(buffer[..length]));
}
[Fact]
public void Pong_RoundTrips()
{
var message = new ServerPongMessage(5, 99);
Span<byte> buffer = stackalloc byte[32];
var length = ProtocolCodec.WritePong(buffer, message);
Assert.Equal(13, length);
Assert.Equal(message, ProtocolCodec.ReadPong(buffer[..length]));
}
[Fact]
public void Snapshot_RoundTripsEveryEntityField()
{
ReadOnlySpan<EntitySnapshot> entities =
[
new EntitySnapshot(7, EntityKind.Player, 100f, 200f, 18f, 0x4CC9F0),
new EntitySnapshot(8, EntityKind.Obstacle, 800f, 450f, 70f, 0x3A4553),
];
var buffer = new byte[ProtocolCodec.SnapshotSize(entities.Length)];
var length = ProtocolCodec.WriteSnapshot(buffer, 1234, entities);
Assert.Equal(buffer.Length, length);
var decoded = new EntitySnapshot[entities.Length];
var count = ProtocolCodec.ReadSnapshot(buffer, decoded, out var tick);
Assert.Equal(entities.Length, count);
Assert.Equal(1234u, tick);
Assert.Equal(entities[0], decoded[0]);
Assert.Equal(entities[1], decoded[1]);
}
[Fact]
public void SnapshotSize_MatchesTheLayoutTheClientAssumes()
{
// 1 type + 4 tick + 2 count, then 21 bytes per entity.
Assert.Equal(7, ProtocolCodec.SnapshotSize(0));
Assert.Equal(7 + 21, ProtocolCodec.SnapshotSize(1));
Assert.Equal(21, ProtocolConstants.EntitySnapshotSize);
}
[Fact]
public void Numbers_AreLittleEndian()
{
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WriteInput(buffer, new ClientInputMessage(0x01020304, InputButtons.None));
Assert.Equal((byte)MessageType.ClientInput, buffer[0]);
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, buffer[1..5].ToArray());
Assert.Equal(6, length);
}
[Fact]
public void PeekMessageType_ReadsTheFirstByte()
{
Span<byte> buffer = stackalloc byte[16];
ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
Assert.Equal(MessageType.ClientPing, ProtocolCodec.PeekMessageType(buffer));
Assert.Equal(MessageType.None, ProtocolCodec.PeekMessageType([]));
}
[Fact]
public void TruncatedFrame_Throws()
{
byte[] frame = [(byte)MessageType.ServerWelcome, ProtocolConstants.Version];
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadWelcome(frame));
}
[Fact]
public void WrongMessageId_Throws()
{
Span<byte> buffer = stackalloc byte[16];
var length = ProtocolCodec.WritePing(buffer, new ClientPingMessage(1));
var frame = buffer[..length].ToArray();
Assert.Throws<ProtocolException>(() => ProtocolCodec.ReadInput(frame));
}
[Fact]
public void OversizedName_Throws()
{
var message = new ClientHelloMessage(ProtocolConstants.Version, new string('x', 100));
var buffer = new byte[256];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteHello(buffer, message));
}
[Fact]
public void UndersizedBuffer_Throws()
{
var message = new ServerWelcomeMessage(ProtocolConstants.Version, 1, 20, 1f, 1f);
var buffer = new byte[4];
Assert.Throws<ProtocolException>(() => ProtocolCodec.WriteWelcome(buffer, message));
}
}
@@ -0,0 +1,224 @@
using HSchool.Protocol;
namespace HSchool.Simulation.Tests;
public class GameWorldTests
{
private static SimulationOptions Options() => new()
{
TickRate = 20,
WorldWidth = 1000f,
WorldHeight = 1000f,
PlayerSpeed = 200f,
PlayerRadius = 10f,
};
private static EntitySnapshot Entity(GameWorld world, uint networkId)
{
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
return buffer.Single(entity => entity.Id == networkId);
}
[Fact]
public void Tick_AdvancesTheTickCounter()
{
using var world = new GameWorld(Options());
world.Tick();
world.Tick();
Assert.Equal(2u, world.CurrentTick);
}
[Fact]
public void SpawnPlayer_AddsAPlayerEntityToSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
Assert.Equal(1, world.PlayerCount);
Assert.Equal(EntityKind.Player, Entity(world, networkId).Kind);
}
[Fact]
public void SpawnPlayer_Twice_Throws()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
Assert.Throws<InvalidOperationException>(() => world.SpawnPlayer(playerId: 1));
}
[Fact]
public void Input_MovesThePlayerAtExactlySpeedTimesDelta()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.Tick();
var expected = startX + (options.PlayerSpeed * options.FixedDeltaTime);
Assert.Equal(expected, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void DiagonalInput_IsNotFasterThanCardinal()
{
var options = Options();
using var world = new GameWorld(options);
var straight = world.SpawnPlayer(playerId: 1);
var diagonal = world.SpawnPlayer(playerId: 2);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 1);
world.ApplyInput(playerId: 2, InputButtons.Right | InputButtons.Down, sequence: 1);
world.Tick();
var straightBefore = Entity(world, straight);
var diagonalBefore = Entity(world, diagonal);
world.Tick();
var straightStep = Distance(straightBefore, Entity(world, straight));
var diagonalStep = Distance(diagonalBefore, Entity(world, diagonal));
Assert.Equal(straightStep, diagonalStep, tolerance: 0.01f);
}
[Fact]
public void Player_StopsAtTheWorldBounds()
{
var options = Options();
using var world = new GameWorld(options);
var networkId = world.SpawnPlayer(playerId: 1);
world.ApplyInput(playerId: 1, InputButtons.Left, sequence: 1);
for (var i = 0; i < 200; i++)
{
world.Tick();
}
Assert.Equal(options.PlayerRadius, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void StaleInput_IsIgnored()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
var startX = Entity(world, networkId).X;
world.ApplyInput(playerId: 1, InputButtons.None, sequence: 10);
world.ApplyInput(playerId: 1, InputButtons.Right, sequence: 2);
world.Tick();
Assert.Equal(startX, Entity(world, networkId).X, tolerance: 0.001f);
}
[Fact]
public void InputForAnUnknownPlayer_IsIgnored()
{
using var world = new GameWorld(Options());
world.ApplyInput(playerId: 999, InputButtons.Right, sequence: 1);
world.Tick();
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void DespawnPlayer_RemovesItFromSnapshots()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.Equal(0, world.PlayerCount);
Assert.DoesNotContain(buffer, entity => entity.Id == networkId);
Assert.Null(world.GetNetworkId(playerId: 1));
}
[Fact]
public void DespawnPlayer_Twice_IsHarmless()
{
using var world = new GameWorld(Options());
world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
Assert.Equal(0, world.PlayerCount);
}
[Fact]
public void NetworkIds_AreNotRecycledAfterDespawn()
{
using var world = new GameWorld(Options());
var first = world.SpawnPlayer(playerId: 1);
world.DespawnPlayer(playerId: 1);
var second = world.SpawnPlayer(playerId: 1);
Assert.NotEqual(first, second);
}
[Fact]
public void EmptyWorld_StillContainsTheStaticObstacles()
{
using var world = new GameWorld(Options());
var buffer = new List<EntitySnapshot>();
world.CaptureSnapshot(buffer);
Assert.NotEmpty(buffer);
Assert.All(buffer, entity => Assert.Equal(EntityKind.Obstacle, entity.Kind));
}
[Fact]
public void Simulation_IsDeterministicForTheSameInputs()
{
var first = Run();
var second = Run();
Assert.Equal(first, second);
static (float X, float Y) Run()
{
using var world = new GameWorld(Options());
var networkId = world.SpawnPlayer(playerId: 3);
for (var i = 0; i < 25; i++)
{
world.ApplyInput(playerId: 3, i % 2 == 0 ? InputButtons.Right : InputButtons.Down, (uint)i + 1);
world.Tick();
}
var entity = Entity(world, networkId);
return (entity.X, entity.Y);
}
}
[Fact]
public void UsingADisposedWorld_Throws()
{
var world = new GameWorld(Options());
world.Dispose();
Assert.Throws<ObjectDisposedException>(world.Tick);
}
private static float Distance(EntitySnapshot from, EntitySnapshot to)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
return MathF.Sqrt((dx * dx) + (dy * dy));
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Simulation.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>