Initial commit: Transcience MVP

Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
2026-09-03 16:03:57 +02:00
commit c4beeae38f
385 changed files with 28725 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
# Architecture
## The shape
```
┌──────────────────────────────────────────┐
│ src/sim/ -- plain RefCounted objects │
authoritative │ SimWorld, SimPlayer, SimEnemy, SimBoss, │ replica
(server) │ BulletPool, emitters │ (client)
│ no nodes · no physics · no rendering │
└───────────────┬──────────────────────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ src/instances/ │ │ src/net/ │ │ src/view/ ui/ │
│ lobby, dungeon │ │ codec, server & │ │ read-only draw │
│ progression │ │ client runtimes │ │ │
└────────────────┘ └────────┬────────┘ └─────────────────┘
┌────────▼────────┐
│ src/autoload/ │
│ net.gd (RPC) │
└─────────────────┘
```
Dependencies point inwards only. `src/sim/` imports nothing from `net`, `view`,
`ui` or `instances`, and never touches the `Net` autoload. That is what makes
the simulation runnable in a unit test, in a headless server and inside a client
without a single conditional.
## Why the simulation is not made of nodes
Godot's instinct is `CharacterBody2D` + `Area2D` + `MultiplayerSynchronizer`.
This project deliberately does not, for four reasons:
1. **Bullet count.** Hundreds of live bullets. A node and an `Area2D` each would
cost more than the entire simulation does. `BulletPool` is parallel packed
arrays; a tick over ~350 bullets costs 0.24 ms including AI and hit checks.
2. **Headless cost.** The dedicated server allocates no nodes and touches no
physics server.
3. **Testability.** A `SimWorld` is `SimWorld.new()`. The suite drives thousands
of ticks in ~2 s with no SceneTree, no awaits and no frame timing.
4. **Replication control.** `MultiplayerSynchronizer` is excellent for
prototyping and wrong for this shape: it would replicate per-bullet state and
saturate the link. See [NETCODE.md](NETCODE.md).
The trade is that there is no physics engine — no arbitrary collision shapes, no
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
convention anyway. A dungeon with real geometry would need a static collision
representation added to `SimWorld`, not a switch back to nodes.
## Tick
Everything runs on Godot's physics tick, pinned to 60 Hz in `src/main.gd`
(asserted in code, because `project.godot` drops settings equal to the engine
default). `SimConfig` holds every constant both sides must agree on; per-machine
options live in `GameOpts` and must never affect the simulation.
## Content pipeline
```
BulletEmitter (Resource, abstract)
├── RingEmitter count, arc, spin per shot -> rings and spirals
├── AimedSpreadEmitter fan at nearest player -> punishes standing still
├── WallGapEmitter curtain with a sliding gap -> forces a committed dodge
└── ArcSweepEmitter rotating arms -> forces tracking
EnemyDef = stats + movement enum + emitters
BossPhase = hp threshold + telegraph + looping timeline of emitters
BossDef = stats + ordered phases
```
`SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can
be dropped on either. A boss is four phases layering one idea at a time; a new
boss is a new function in `src/content/content.gd` and zero simulation changes.
## Entry point
One executable, one `src/main.tscn`. `GameOpts.parse()` reads the command line
after `--`:
- `--server``ServerRuntime`, no view, `max_fps` pinned to the tick rate.
- `--join` / `--bot` → connect immediately, skipping the menu.
- otherwise → the connect menu, which can also start a listen server.
## File map
| File | Role |
| --- | --- |
| `src/core/sim_config.gd` | Every constant server and client must agree on. |
| `src/core/movement.gd` | Pure movement + overlap helpers. Shared by prediction. |
| `src/core/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. |
| `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. |
| `src/sim/input_frame.gd` | The only thing a client may assert about itself. |
| `src/net/net_codec.gd` | Snapshot / event / input binary codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. |
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
| `src/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |