Files
h-school/AGENTS.md
T

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.

  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.