# 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 `), 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 `` 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.