This document defines the initial technical architecture for BulletHeaven. Its decisions are intended to let multiple contributors implement compatible systems while preserving the control feel, systemic interactions, readability, and performance goals in GOALS.md.
Architecture decisions remain subject to measured prototype results. A change to a committed decision must be recorded rather than introduced silently.
| Area | Decision | Status |
|---|---|---|
| Language | Java 22 | Committed for the desktop prototype |
| Game framework | libGDX | Committed |
| Desktop backend | LWJGL3 | Committed |
| Build | Gradle wrapper with core and lwjgl3 modules |
Committed for Tier 0 |
| ECS | Ashley with project-owned components, systems, factories, and queries | Committed through the Tier 2 benchmark |
| Physics | Project-owned 2D collision and force systems; no general physics engine | Committed |
| Simulation | Fixed timestep with interpolated rendering | Committed |
| Rendering | libGDX OpenGL abstraction, batched vector meshes, shaders, and framebuffer effects | Committed |
| UI | libGDX Scene2D UI, kept outside gameplay ECS | Proposed |
| Content | Versioned JSON definitions validated at startup | Committed |
| Testing | JUnit 5 plus headless simulation fixtures and deterministic replays | Committed |
| Initial platform | Windows desktop | Committed for initial implementation |
Use libGDX for the application lifecycle, graphics abstraction, windowing, input, controllers, audio, files, and UI. Use its LWJGL3 desktop backend.
Reasons:
- The game requires a mature Java desktop runtime with direct control over the game loop and rendering.
- libGDX supports Java 22 for desktop targets.
- LWJGL3 is libGDX's standard desktop backend.
- The framework provides controller, audio, framebuffer, shader, mesh, and headless-testing capabilities without prescribing game structure.
- It keeps future desktop portability possible without committing the initial release to additional platforms.
Do not use Box2D or the Bullet physics wrapper. BulletHeaven needs predictable circles, polygons, spatial queries, impulses, and arena forces rather than a full rigid-body simulation.
The repository currently contains an empty Maven project. Tier 0 will replace the placeholder build with a checked-in Gradle wrapper and the standard libGDX desktop structure:
BulletHeaven/
assets/
core/
src/main/java/
src/main/resources/
src/test/java/
lwjgl3/
src/main/java/
build.gradle
settings.gradle
gradle.properties
gradlew
gradlew.bat
corecontains platform-independent simulation, gameplay, content, and rendering code that depends only on libGDX core APIs and explicitly approved libraries.lwjgl3contains the desktop launcher, native/backend dependencies, window configuration, and desktop packaging.- Assets live in one canonical asset root and are addressed through stable logical paths.
- All library versions are pinned centrally.
- Snapshot library versions are prohibited on the main branch.
- The Gradle wrapper is the authoritative build entry point; a system Gradle installation is not required.
The existing Maven file should be removed only as part of an isolated, reviewable Tier 0 migration after the Gradle build can run the same empty application and tests.
Start with the smallest useful dependency set:
- libGDX core.
- libGDX LWJGL3 backend and desktop natives.
- libGDX controller extension.
- Ashley ECS extension.
- JUnit 5 for tests.
- A single JSON implementation if libGDX JSON proves insufficient for versioned validation and useful error reporting.
New runtime libraries require a recorded reason, license check, and confirmation that their functionality is not already provided by the chosen stack.
- Use Java 22 language and runtime features only when they improve clarity or correctness.
- Avoid incubator, preview, and internal JDK APIs.
- Prefer immutable records for value objects and content definitions where framework serialization permits.
- Prefer explicit ownership and plain data over reflective dependency injection.
- Do not use global mutable service locators.
- Nullability expectations must be obvious from APIs; optional data uses explicit absence where ambiguity would be harmful.
- Simulation code must not depend on wall-clock time, default locale, filesystem order, or unseeded randomness.
Java 22 currently limits the project to desktop libGDX targets. A future mobile or web port would require revisiting the language target and dependency choices.
- One to three similar items may remain local when they stay cohesive and readable.
- Four to ten similar items must be extracted into focused classes behind a shared interface or base type when polymorphism is useful.
- More than ten similar items must be created or registered through a factory or data-driven registry instead of an expanding conditional chain.
Inheritance is not required merely to satisfy the middle tier. Prefer composition and small interfaces for ECS behavior; use subclasses only when types genuinely share invariant implementation. Factories own construction, not simulation behavior.
CombatWorld is the simulation coordinator, not the permanent home of enemy systems, entity recipes, encounter selection, and rendering-facing queries. Refactoring should move these responsibilities toward a shared CombatContext, focused top-level systems, an EntityFactory, and an EncounterDirector.
The runtime is divided into five layers:
Desktop launcher
|
Application and screen flow
|
Gameplay orchestration and simulation
|
ECS, content definitions, and deterministic services
|
libGDX platform, rendering, input, audio, and files
Dependencies point downward. Lower layers do not call screen classes or desktop launchers.
com.marginallyclever.bulletheaven
app application lifecycle and screen transitions
platform platform-facing interfaces and adapters
input abstract actions, devices, mappings, and snapshots
sim simulation clock, world, random streams, and commands
ecs
component data-only Ashley components
system ordered gameplay systems
family centralized entity queries
factory validated entity construction
combat damage, weapons, targeting, factions, and effects
encounter spawning, budgets, waves, bosses, and run pacing
environment arena-wide effects, forces, mass, and anchoring
progression XP, schools, upgrades, adaptations, and rewards
content definitions, identifiers, loading, and validation
render render extraction, vector batches, shaders, and effects
ui HUD, menus, choices, settings, and reports
audio cues, mixing, and music state
replay seeds, input recording, checksums, and playback
diagnostics overlays, metrics, logging, and debug scenarios
Package boundaries are not permission to duplicate concepts. Damage, ownership, force, and targeting each have one authoritative vocabulary.
The application owns long-lived platform services and transitions among explicit screens or modes:
- Boot and content validation.
- Main menu.
- Run setup.
- Gameplay.
- Paused gameplay.
- Run report.
- Settings and controls.
- Diagnostic scenarios in development builds.
The gameplay world is created for a run and destroyed at its end. UI screens must not retain gameplay entities after disposal.
Pause, upgrade choice, ability-core choice, transition, victory, and defeat are explicit simulation states. A modal UI cannot independently decide whether simulation continues.
Use a fixed simulation timestep with an accumulator. Begin prototyping at 120 simulation steps per second and validate this rate against control feel and CPU cost during Tiers 1 and 2.
- Rendering may occur at a different or variable rate.
- A render alpha interpolates between previous and current transforms.
- A frame processes only a bounded number of catch-up steps.
- If the application falls far behind, it records the event and discards excess accumulated wall time rather than entering an endless catch-up spiral.
- Pause and reward-decision states stop simulation time without corrupting the accumulator.
- Timers, cooldowns, lifetimes, spawn schedules, and status durations use simulation time.
System execution order is explicit and covered by an integration test. The initial order is:
- Apply the immutable input snapshot for the step.
- Resolve run and encounter commands scheduled for the step.
- Update player intent and AI intent.
- Activate weapons, abilities, spawns, and timed effects.
- Accumulate arena forces and other accelerations.
- Integrate velocity and position.
- Update spatial indices.
- Detect collisions and trigger overlaps.
- Resolve damage, shields, status effects, impulses, and deaths.
- Resolve drops, XP collection, and progression events.
- Queue entity creation and removal.
- Commit deferred structural changes.
- Produce metrics, replay checksums, and render snapshots.
Changing system order is an architectural change because it can change gameplay and replays.
Ashley supplies entity identity, component storage, families, engine integration, and system scheduling. Project code owns the gameplay vocabulary.
- Components contain data and no gameplay decisions.
- Systems implement behavior over component families.
- Entity factories assemble validated component sets from content definitions.
- Game code does not subclass
Entityfor enemy or projectile types. - Code does not use concrete enemy classes as behavior switches.
- Queries and component mappers are centralized rather than recreated throughout the codebase.
- Entity addition and removal occur through a deferred command boundary during active simulation.
Ashley remains the ECS only if the Tier 2 representative benchmark meets the documented entity and frame-time budgets. If it fails, the benchmark and public project-owned interfaces become the migration specification for a more data-oriented store.
Components are grouped by concern. Likely components include:
- Identity: stable content identifier, display identity, debug label.
- Transform: current and previous position, rotation, scale.
- Motion: velocity, acceleration, maximum speed, drag.
- Body: collision shape, radius or polygon reference, collision layer, collision mask.
- Mass: mass class, inverse mass, environmental resistance.
- Anchor: anchor state and break condition.
- Faction: player, friendly, hostile, neutral, or environmental ownership.
- Health: current and maximum health.
- Shield: capacity, recharge delay, and recharge rate.
- Damage: amount, type, impulse, source, and hit rules.
- Lifetime: remaining simulation time and expiry action.
- Weapon: fire interval, projectile definition, muzzle, and state.
- Projectile: owner, hit history policy, piercing, and reflection state.
- Targeting: target rules, range, and current target.
- AI intent: desired movement, facing, firing, and activation.
- Status set: active status instances using stable effect identifiers.
- Drop table: XP and ability-core rules.
- Renderable: shape definition, palette role, layer, material, and visibility flags.
- Telegraph: warning geometry, timing, and priority.
- Familiar: formation slot, owner, role, and recovery behavior.
- Environment affected: force eligibility and effect filters.
- Replication: copy eligibility, generation, source, and entity budget.
This list is a vocabulary proposal, not a requirement to create every component before its first use.
Use typed, short-lived event records for facts produced during a simulation step, such as hit, damage, death, shield break, XP collected, or reward requested.
- Events describe what happened; they do not hold callbacks.
- Event buffers are owned by the world and cleared at known points.
- Commands request structural or state changes that cannot occur immediately.
- Cross-system communication does not use an untyped global message bus.
- Long-lived state belongs in components or explicit run services, not in event history.
Every spawn uses a stable content definition and a project-owned factory.
- Factories reject missing required components and invalid combinations.
- Spawn commands include owner, transform, seed lineage, and cause.
- Copies and splits record generation depth.
- Projectiles, familiars, secondary effects, structures, drops, and particles have separate budgets.
- Mechanical entities receive priority over cosmetic entities.
- When a budget is reached, the defined behavior degrades predictably: for example, consolidate shards, suppress cosmetic particles, or replace many fragments with an equivalent aggregate effect.
- A budget limit must not silently delete a guaranteed reward, boss mechanic, or required telegraph.
Pooling is introduced only after profiling. Pooled components must be fully reset and covered by reuse tests.
- Simulation uses a right-handed 2D world measured in world units, independent of pixels.
- Positive X points right and positive Y points up.
- Angles have one canonical unit and normalization rule throughout simulation APIs; radians are preferred internally.
- Rendering converts world coordinates through an orthographic camera.
- UI uses screen or stage coordinates and never participates in gameplay collision.
- Common geometry functions live in one tested module.
Collision shapes begin with circles and convex polygons. Most high-volume entities should use circles where visual fidelity does not require a polygon.
Implement a project-owned broad phase using a uniform spatial grid sized from representative entity dimensions.
- Dynamic collision bodies occupy one or more grid cells.
- Static or anchored structures may use a separate index.
- Queries reuse buffers to avoid per-step allocation.
- Pair generation prevents duplicate resolution.
- Collision layers and masks reject impossible pairs before narrow-phase work.
Support only required tests:
- Circle versus circle.
- Circle versus segment or capsule for beams and walls.
- Circle versus convex polygon.
- Convex polygon versus convex polygon only if demonstrated necessary.
- Swept tests for fast projectiles when discrete overlap would permit tunneling.
Gameplay collision is trigger-oriented. It produces typed contacts for damage, pickup, blocking, attachment, or force response. It does not attempt general rigid-body stacking.
Projectile definitions state whether they:
- Expire on first hit.
- Pierce a limited number of targets.
- Can hit the same target more than once.
- Reflect or change ownership.
- Split, copy, or trigger an impact effect.
Per-target hit history must be bounded and avoided when simpler lifetime or cooldown rules work.
All movement-changing mechanics write to a shared force accumulator or issue explicit impulses.
- Continuous effects apply force or acceleration during affected steps.
- Epoch Wave applies one impulse per eligible entity per wave pass.
- Gravity Spiral computes radial and tangential components from the same documented field function.
- Mass and environmental resistance modify displacement consistently.
- Anchored entities remain fixed while their anchor is valid.
- Steering acts alongside environmental force rather than overwriting velocity.
- Teleportation and direct position correction are separate operations and never masquerade as force.
Traveling fronts carry stable identifiers so an entity can record that a particular front has already affected it. These records are bounded by the number of active fronts.
Damage uses a single request-and-result pipeline:
- Validate source, target, faction, and hit policy.
- Apply avoidance or invulnerability.
- Apply directional blocks, reflection, or deflection.
- Apply shields and breakable armor.
- Apply damage reduction and resistance.
- Reduce health.
- Apply permitted impulse and statuses.
- Emit typed results such as blocked, shield broken, damaged, or killed.
An effect definition describes trigger, conditions, target selection, operations, cooldown, and entity budget. School upgrades and captured abilities should assemble these shared operations rather than execute arbitrary scripts.
Avoid a general-purpose scripting language for the initial release. Add scripting only if data-driven effect composition proves unable to express required content cleanly.
Determinism is required for repeatable tests, seeded runs, and useful bug reports. Exact cross-machine bitwise determinism is a goal for simulation-relevant outcomes but must be validated rather than assumed for floating-point geometry.
- A run begins with one recorded root seed.
- Named random streams derive from the root for encounters, drops, upgrade offers, AI variation, and cosmetic randomness.
- Cosmetic randomness cannot consume values from gameplay streams.
- Entity creation uses deterministic sequence identifiers within a run.
- Iteration that affects outcomes uses explicit stable ordering when order matters.
- Hash-map or filesystem iteration order cannot determine gameplay.
- Replays store version, seed, initial configuration, and per-step abstract input snapshots.
- Periodic checksums identify the first divergent simulation step.
Changing deterministic behavior may invalidate old development replays. Release save compatibility and replay compatibility are separate policies.
Platform input is sampled into device state and converted into an immutable abstract input snapshot for each simulation step.
The simulation consumes actions, not raw keys, buttons, or cursor APIs:
- Move vector.
- Aim vector or world-space aim target.
- Fire held/pressed/released.
- Movement ability pressed.
- Active ability pressed.
- Interact/confirm pressed.
- Pause pressed.
Bindings, dead zones, aim curves, device switching, and accessibility assists belong in the input layer. Replays record abstract snapshots after mapping and assistance so playback does not depend on attached hardware.
Rendering reads simulation state but does not mutate it.
- At the end of a simulation step, render-relevant state is available through components or a compact render snapshot.
- Transform interpolation uses previous and current simulation state.
- Rendering order is explicit: arena background, environment fields, structures, pickups, enemies, familiars, player, projectiles, telegraphs, particles, post-processing, and UI, with exceptions documented by visual priority.
- Critical telegraphs can request a high-priority layer that remains visible through effects.
Use generated meshes and batched primitive data rather than raster sprites for primary entities.
- Content definitions reference reusable shape identifiers.
- Shape geometry is cached, not rebuilt every frame.
- Instance data supplies transform, palette role, glow, outline, and animation parameters.
- Start with libGDX mesh and shader abstractions; introduce lower-level OpenGL calls only when profiling justifies them.
ShapeRenderermay support diagnostics and the earliest prototype but is not the planned high-density release renderer.- Future work: Move responsive-grid deformation from CPU-generated line samples into a custom GPU shader. Supply player/enemy mass sources and arena effects such as Gravity Spiral and Epoch Wave as bounded shader inputs, allowing a denser and smoother grid with less per-frame CPU geometry work. Retain the current CPU renderer as a compatibility fallback.
- Render emissive content to a framebuffer.
- Produce glow through a bounded-resolution blur and composite pass.
- Keep UI and critical telegraphs outside destructive post-processing.
- Reduced-effects mode lowers glow resolution, particles, distortion, shake, and flashes without removing mechanical information.
- Shader failure falls back to a readable non-glow presentation where possible.
- Use an orthographic world camera with resolution-independent world framing.
- Camera motion is visually smoothed but never used as simulation position.
- Screen shake is additive, bounded, and independently configurable.
- Cursor-to-world aim uses the unshaken logical camera so shake does not alter firing direction.
Use Scene2D UI for menus, HUD layout, upgrade choices, settings, and run reports.
- Gameplay state owns the facts displayed by UI.
- UI emits typed commands and does not directly edit ECS components.
- HUD updates avoid allocating strings and widgets every frame.
- Layout supports common aspect ratios, UI scale, and safe margins.
- Keyboard, mouse, and gamepad can operate every menu.
- Modal decisions coordinate with the explicit simulation state machine.
If Scene2D proves too costly or restrictive for the high-frequency HUD, specialized HUD rendering may be used behind the same presentation model.
Use libGDX audio APIs behind a project-owned audio service.
The current vertical-slice implementation uses GameAudio, a dedicated streaming procedural mixer. Gameplay and menus emit semantic cues without knowing how they are synthesized. This establishes routing, lifecycle, independent effects/music levels, and mute behavior before authored sound assets are selected; authored assets can replace synthesis behind the same cue interface.
- Simulation emits semantic audio cues such as weapon fired, shield broken, sniper locked, Epoch Wave warning, or reward appeared.
- The audio layer selects files, pitch variation, volume, priority, and concurrency limits.
- High-frequency sounds have voice limits and aggregation rules.
- Music responds to encounter state, not entity implementation details.
- Gameplay-critical audio cues have visual equivalents.
- Master, music, effects, ambience, and interface levels are independently adjustable.
Audio does not affect deterministic simulation.
Enemies, weapons, upgrades, adaptations, encounters, drop tables, shapes, palettes, and environment parameters use versioned JSON definitions where behavior can be composed from known operations.
Every content item has a lowercase namespaced identifier, for example:
enemy:mote
upgrade:mercury/winged_sandals
adaptation:dart/vector_charge
environment:gravity_spiral
shape:enemy/dart
Display names are localizable data and are never used as persistence or code keys.
- Parse content into immutable definition objects during boot.
- Validate identifiers, references, ranges, prerequisites, cycles, required tags, and incompatible operations.
- Report every discoverable validation error in one run with file and logical path.
- Convert definitions into runtime factory inputs only after the entire content set is valid.
- Development builds may support deliberate content reload at a safe boundary; release builds do not require hot reload.
- Unknown schema versions fail with an actionable message.
Arbitrary Java class names do not appear in content files. A registry maps approved operation identifiers to tested implementations.
Separate user configuration, durable progression, and run/replay data.
- Settings save immediately after a confirmed change.
- Save files include schema version and application version.
- Writes use a temporary file and atomic replacement where supported.
- Retain a previous known-good copy for recovery.
- Invalid data produces safe defaults or a recovery choice rather than preventing launch.
- No initial feature requires an online account or service.
- Persistent progression remains deferred, so its schema must not be invented during Tier 0.
Cover deterministic and boundary-heavy logic:
- Vector and geometry functions.
- Fixed-step timing.
- Damage pipeline.
- Upgrade stacking and prerequisites.
- Challenge and spawn budgets.
- Drop selection and dry-streak protection.
- Content parsing and validation.
- Force fields and traveling-front hit-once behavior.
- Save migration and recovery.
Create headless worlds that execute ordered systems:
- Projectile collision through damage and death.
- Death through XP and ability drops.
- Upgrade choice through component changes.
- Reflection and faction ownership.
- Duplication generation and entity budgets.
- Gravity Spiral across different masses.
- Epoch Wave across player, enemy, projectile, familiar, pickup, and anchored structure.
- Pause and decision-state timing.
Every enemy, upgrade, adaptation, and environment effect receives an isolated runnable scenario. A scenario declares its seed, initial entities, expected invariants, and optional visual inspection notes.
Store a small suite of short recorded inputs with periodic expected checksums. Replay failures report the first divergence and relevant system metrics.
Capture stable diagnostic scenes for human or controlled image comparison. Visual tests support review but do not replace gameplay assertions.
Performance work is measurement-driven.
- Simulation, render, and total frame time percentiles.
- Time per major system.
- Entity counts by category.
- Collision candidates and resolved contacts.
- Spawn and removal counts.
- Allocations and garbage-collection pauses.
- Draw calls, rendered primitives, framebuffer sizes, and particle counts.
- Dropped simulation time and catch-up events.
Tier 2 defines a representative worst-case benchmark containing active AI, collision, projectiles, drops, forces, duplication, and rendering. It must not use inert placeholder entities as its only evidence.
- Establish reference hardware before accepting the benchmark.
- Record median and high-percentile frame times, not only averages.
- Run a headless simulation benchmark and a full-render benchmark.
- Keep benchmark definitions in source control.
- A performance regression beyond the agreed tolerance blocks integration unless explicitly accepted.
- Avoid per-frame and per-entity temporary allocations in hot systems.
- Reuse query, collision, event, and render buffers.
- Prefer primitive fields in high-volume components.
- Do not contort cold setup or UI code before profiling identifies a problem.
Development builds provide:
- Frame-time and system-time overlay.
- Entity and budget counts.
- Collision-grid visualization.
- Component inspection for a selected entity.
- Force-vector and arena-field visualization.
- Spawn controls and time scaling.
- Seed display and replay recording.
- Invulnerability and controlled upgrade grants.
- Direct launch into isolated scenarios.
- Logging of content-validation and deterministic-divergence errors.
Debug tools issue the same commands as normal gameplay where practical. They must not introduce production dependencies into the simulation.
Begin with a single simulation thread and the render thread model required by libGDX.
- Do not parallelize ECS systems during early development.
- Background threads may load or parse data that does not touch OpenGL or live simulation state.
- Results cross thread boundaries as immutable data and are committed at explicit safe points.
- OpenGL operations stay on the render thread.
- Audio access follows backend requirements.
- Add parallel simulation only after profiling proves a specific system is a bottleneck and deterministic ownership is designed.
Predictability is more valuable than speculative concurrency.
- Programmer invariant failures throw or assert early in development builds.
- Invalid content fails during boot with aggregated actionable messages.
- Recoverable runtime problems log context and use an explicit fallback.
- Exceptions are not used for ordinary per-frame control flow.
- A crash report records application version, platform, seed, current encounter, entity counts, and recent structured events without collecting personal files.
Parallel work must respect subsystem ownership.
- Shared interfaces, component schemas, content schemas, system order, and package moves require technical-lead review.
- An agent may add a component only with its owner, reset behavior, factory behavior, and serialization/debug implications defined.
- Content agents work through definitions and approved effect operations rather than inserting content-specific branches into shared systems.
- Rendering never becomes the authoritative owner of gameplay state.
- UI never mutates components directly.
- Tests accompany new shared mechanics and content operations.
- Each change identifies the DEVPLAN.md completion criterion it advances.
- Large mechanical changes include an isolated diagnostic scenario.
Avoid assigning two active agents overlapping write ownership of the same foundational files. Integrate small vertical changes before stacking more work on an unverified abstraction.
Material changes use short records under docs/adr/ containing:
- Context.
- Decision.
- Alternatives considered.
- Consequences.
- Validation or reversal condition.
The first records should cover framework/build selection, ECS selection, fixed-step rate, and content format once their Tier 0 or Tier 2 validation is complete.
Tier 0 implementation is complete only when:
- The Gradle wrapper builds and runs
coretests and thelwjgl3application from a clean checkout. - The application opens a resizable empty arena using libGDX and LWJGL3.
- Keyboard/mouse and gamepad input produce the same abstract actions.
- A fixed-step simulation runs independently of rendering rate.
- Headless tests run without opening a window.
- The diagnostics overlay shows frame time, simulation steps, and active input device.
- One versioned content file loads and validates through the planned content pipeline.
- Package boundaries and system-order tests exist.
- Build, run, test, and packaging commands are documented.
The following are explicit experiments rather than untracked ambiguity:
- Validate 120 Hz simulation against 60 Hz during the combat-feel and ECS benchmarks.
- Validate Ashley under the representative Tier 2 workload.
- Validate generated mesh batching and glow passes at maximum intended visual density.
- Validate Scene2D for the HUD and reward-choice interfaces.
- Decide whether libGDX JSON provides sufficient versioning and validation before adding another JSON library.
- Establish reference hardware, entity budgets, draw-call budgets, and memory targets during Tier 2.
- Determine the supported desktop operating systems before release packaging begins.
- libGDX project generation
- libGDX desktop launcher and LWJGL3 configuration
- libGDX deployment
- Bundling a Java runtime
- libGDX Maven integration limitations
These references inform the initial stack decision. Exact dependency versions must be checked and pinned when Tier 0 implementation begins.
instead of writining Math.min(Math.max(value, min), max) everywhere, use clamp(value, min, max).