Files
transcience/docs/ARCHITECTURE.md
claude b8332b697d
ci / verify (push) Successful in 49s
Documentation pass for a cold start
Audited the whole set rather than appending to it, and the two files a fresh
session reads first were both wrong.

README claimed the emergency escape takes three seconds and is CANCELLED BY
DAMAGE. It takes one, and damage explicitly does not interrupt it -- that is a
settled decision with its own entry in DECISIONS.md, and the front page said
the opposite. It also described one stationary boss, one hub portal, and none
of characters, permadeath, levels, inventory, loot, upgrades, settings or
credits. Rewritten, with the "not ready for the internet" warning made explicit.

ROADMAP had no "what is next" at all: every stage reads *done*, which for a
cold start is a dead end. It opens with where things stand and a table of
candidates -- auth, a reason to play past level 15, an economy, hit feedback,
replacing the non-redistributable packs, DTLS -- each with what blocks it, and
says plainly that the user has chosen none of them. Open questions renumbered
from the orphaned 8-11 they were left at, with the solved one dropped and three
real ones added.

ARCHITECTURE's file map listed two files twice, missed five subsystems
(upgrades, stats, poison, settings, credits, the UI theme), and still said
MapGen.build() is "the entry point both sides use" -- it is server-only, and
the whole anti-map-hack story depends on that. Rebuilt by layer and audited
against the tree: every path listed exists, and every one of the 64 source
files is covered.

CLAUDE.md's security paragraph said a client can send "exactly two things"
plus two roster requests. There are five client -> server messages. That number
is the security model, so it is now a table naming each one and what it
carries. Also records that nothing automated can see the screen.

Smaller: a stale 156/156 test count in ASSETS.md, and test counts refreshed to
468 where they are quoted.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green, no broken
internal links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:20:10 +02:00

11 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, so collision is hand-written. Since Stage 1 that means a tile grid (MapGrid): circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre convention anyway.

That was the right call rather than a compromise: the same grid answers collision, sight, and per-peer interest management as array lookups. On arbitrary polygons all three become intersection tests, and the fog query in particular stops being cheap enough to run every frame.

What it costs: geometry is axis-aligned and 32px-quantised, and bullets must travel less than one tile per tick or the point test steps over walls (pinned by test_bullet_speeds_stay_below_the_tunnelling_threshold).

Where the layers sit now

Four things are worth knowing before reading any file:

  • Geometry is per-world. There is no global arena. SimWorld.map is a MapGrid; the hub and every dungeon have their own, and the client holds a partial copy streamed to it in chunks.
  • src/meta/ is server-only. Accounts, characters, levels, experience, inventories and upgrades live there. The simulation reads a player's level and maximum health and moves items between the ground and a bag; it never writes progression or touches the store. It announces what happened and ServerRuntime banks it. One writer means a level cannot disagree with the experience that earned it.
  • Derived, never stored. Level comes from lifetime experience; combat numbers come from the upgrade list (PlayerStats); maximum health comes from the level and the upgrades. Nothing that can be recomputed is persisted, so nothing saved can disagree with what produced it.
  • src/core/settings.gd is the exception to "shared". Everything else in src/core/ is agreed by both sides; settings are the player's own machine and never reach the server.

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. SimWorld._move_boss() is the same idea for movement: the mode is a field on the phase, and adding a boss that walks needs no code.

Emitters are stateless. They are shared resources — two bosses of the same kind would otherwise stomp each other's timers — so anything an emitter needs to remember between two ticks has to be derived instead. TelegraphedStrikeEmitter computes its strike positions from the volley number for exactly this reason.

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

Grouped by layer. The rule the whole thing hangs on: src/sim/ imports nothing from net, view or ui, and never touches Net.

Shared constants and helpers

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/core/settings.gd Client-local preferences. Never reaches the server.
src/core/credits.gd Third-party assets and their licences. Two are CC BY, so this is a legal requirement.

The simulation

File Role
src/sim/sim_world.gd The simulation. The 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/sim/player_stats.gd Combat numbers derived from a character's upgrades.
src/sim/poison_track.gd Damage over time. O(1) per actor per tick however many doses.
src/sim/sim_loot.gd An item on the ground: world-shared, or owned by one peer.
src/sim/sim_portal.gd A dungeon entrance, and which dungeon it opens.
src/sim/map_grid.gd Tile grid: collision, line of sight, chunked streaming.
src/sim/map_gen.gd Dungeon generation. Server-side only — the client is never given the seed.
src/sim/patterns/ Bullet emitters. Stateless; the authoring surface for every fight.

Content (data, in code)

File Role
src/content/content.gd Every enemy and boss. ALL_ENEMIES / ALL_BOSSES are what the tools and tests iterate.
src/content/items.gd Every item. Items.ORDER doubles as the wire format.
src/content/upgrades.gd The seven upgrades and their draw weights.
src/content/dungeons.gd The kinds of run. ORDER is a wire format and the hub's portal order.
src/content/rooms.gd Hand-authored stamps: the hub and each boss arena, as text.
src/actors/ The Resource definitions those tables build: enemy, boss, item, loot, upgrade, dungeon.

Server-owned state

File Role
src/meta/progression.gd XP curve and what a level is worth. Pure functions.
src/meta/character.gd, character_store.gd Characters and their JSON persistence.
src/meta/auth_provider.gd Identity, shaped like Steamworks so it swaps out.
src/instances/instance.gd The hub and one dungeon run: a world plus a peer list.

Network

File Role
src/net/net_codec.gd Every binary codec: snapshot, events, input, roster, characters, map chunks, portals, upgrade state.
src/net/server_runtime.gd Instances, ticking, transfers, interest, progression, persistence, map streaming.
src/net/client_runtime.gd Prediction, reconciliation, interpolation, bot input.
src/net/protocol.gd Wire version and constants. Bump VERSION whenever a layout changes.
src/autoload/net.gd ENet lifecycle, RPCs, local loopback for listen servers. The only autoload.

View and UI (read-only; decides nothing)

File Role
src/view/world_view.gd Everything that is not a bullet. Fog, actors, loot, telegraphs.
src/view/bullet_renderer.gd The bullet field: one MultiMesh per bullet kind.
src/view/art.gd Every atlas rect and sound path, in one table.
src/view/ui_theme.gd The control theme, built in code from the UI pack.
src/view/debug_draw.gd F1 overlay: what the simulation collides against.
src/view/game_scene.gd, sfx.gd Wiring the view to whatever client Net currently has.
src/ui/hud.gd Bars, inventory, prompts. Drawn, not built from controls.
src/ui/main_menu.gd, game_menu.gd Connect screen and the in-game menu.
src/ui/character_select.gd Roster screen: pick or create.
src/ui/upgrade_screen.gd The quartermaster's three choices, and what you hold.
src/ui/settings_screen.gd, credits_screen.gd Controls, volumes, attribution.