5.6 KiB
Working agreements
Read this before changing anything. It is written for coding agents, and it is just as valid for
humans. 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
dotnet build HSchool.slnx
dotnet test
npm --prefix src/HSchool.Client test
npm --prefix src/HSchool.Client run build
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.
- The server is authoritative. The client sends intents and draws what it is told. No game
logic in
src/HSchool.Client. - The protocol lives in three places at once.
ProtocolCodec.cs,protocol.tsanddocs/protocol.mdchange in the same commit. A layout change bumpsProtocolConstants.Version/PROTOCOL_VERSION. Tests on both sides assert byte offsets — if one of them has to change, so do the other two. - Only the loop thread touches
GameWorld. Everything inbound goes throughGameCommandQueue; everything outbound goes through the per-client outbox. No locks around the ECS world, noTask.Runinto it. - The simulation knows nothing about the network.
HSchool.Simulationmust not reference ASP.NET Core, sockets or logging infrastructure. It stays testable without a host. - Fixed timestep. Systems get
SimulationContext.DeltaTime, never wall-clock time and neverDateTime.Now. Same inputs, same results —Simulation_IsDeterministicForTheSameInputsguards it. - Everything from the wire is untrusted. Validate lengths and ranges in the handler before anything reaches the simulation.
Conventions
C#
- File-scoped namespaces,
varwhere the type is obvious, primary constructors for services. - Private fields are
_camelCase;.editorconfigenforces 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
SimulationOptionswith a default, not as a constant buried in a system.
TypeScript
strictis on, noany, no non-null!assertions.- Relative imports carry the
.tsextension (bundler resolution is configured for it). - Modules stay thin:
net/speaks protocol,game/renders,main.tswires 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
GameWorldtest. 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
PeriodicTimerdoes not catch up on its own. The accumulator inGameLoopServicedoes, capped at 5 steps — do not "simplify" it away.- Arch recycles entity ids. Replicate
NetworkId, neverEntity.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.
erasableSyntaxOnlyis off intsconfig.app.jsonon purpose: constructor parameter properties are used throughout.