Files
transcience/docs/ARCHITECTURE.md
T
claude c4beeae38f 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
2026-09-03 16:03:57 +02:00

5.4 KiB

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.

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 --:

  • --serverServerRuntime, 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.