Proposal: builtin Shooter controller — top-down rotatable movement, weapons registry, projectiles/bullet-hell (base + extensible) #60

Closed
opened 2026-09-01 05:13:41 +02:00 by orkun · 2 comments
Owner

Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract. Planning only — no implementation.

Context

A top-down shooter controller: a character that moves independently of where it aims (twin-stick / mouse-aim), a rotatable body, and a projectile system general enough for bullets, lasers, grenades, homing missiles, and bullet-hell patterns. Modeled on twin-stick classics (Nuclear Throne, Enter the Gungeon), Godot top-down demos, and bullet-hell frameworks (Danmakufu-style pattern emitters). Reuses #57 collision + faction; feeds directly into NPC AI (#NPCAI) enemies and RPG (#RPG) real-time combat.

Layered design

Layer 1 — the top-down mover + aim (data-first)

Movement and facing are decoupled — the defining trait of the genre.

property TopDown {
  move_speed:  int = 6,
  accel:       int = 80,
  friction:    int = 70,
  aim_mode:    int = 0,   # 0 mouse/point, 1 right-stick, 2 move-dir, 3 nearest-enemy (auto)
  turn_rate:   int = 0,   # 0 = instant snap; >0 = degrees/frame (tank-ish turning)
  strafe:      int = 1,   # 1 = body rotates freely of move dir; 0 = face move dir
  aim_angle:   int = 0,   # current facing, degrees (Angle.* auto-wraps)
  policy:      int = 0
}
model Gunner { Pos, Body, Collider, TopDown, Weapon, SpriteAnim }
  • aim_mode covers the real control schemes as data: mouse-aim, right-stick, aim-where-you-move, and auto-aim (nearest enemy via Query.nearest) — the last one reused constantly by AI-controlled shooters.
  • turn_rate 0 = instant (arcade), >0 = gradual (uses Angle.* shortest-arc rotation) for heavier feel.
  • Sub-systems: esys_topdown_move, esys_topdown_aim (only the active aim_mode branch runs), esys_topdown_facing→sprite/rotation.

Layer 2 — weapons (data-driven registry)

A weapon is data, not a subclass — a registry entry like Anim.clip, so games/mods add guns with zero code:

Weapon.def("pistol",  fire_rate: 8,  damage: 10, speed: 20, spread: 2, pellets: 1, pattern: 0)
Weapon.def("shotgun", fire_rate: 2,  damage: 6,  speed: 16, spread: 18, pellets: 8, pattern: 0)
Weapon.def("laser",   ... kind: beam ...)
Weapon.def("wave",    ... pattern: 3 ...)   # bullet-hell arc

property Weapon { def_id, ammo, mag, reload_frames, cooldown, heat }
  • Fields cover the common axes: fire rate, damage, projectile speed, spread, pellet count, ammo/mag/reload, charge, heat/overheat, recoil. kind ∈ {projectile, hitscan/beam, melee-arc}. pattern selects an emitter (single, spread, ring, arc, spiral) — a policy for bullet-hell.
  • esys_weapon handles fire-rate Cooldown, ammo, reload, and burst/auto/charge fire modes; reads a "fire" input action (or an AI intent flag, so the same system drives player and NPC guns).

Layer 3 — projectiles (the reusable core)

property Projectile {
  vx, vy, damage, ttl, pierce, bounce,
  homing,        # 0 none; >0 = turn rate toward Target
  gravity,       # 0 for top-down; >0 for lobbed grenades
  owner, faction, on_hit   # on_hit policy: destroy/explode/stick/spawn
}
  • esys_projectile (phase Update): integrate, TTL countdown, collision vs. Collider layers/faction, pierce/bounce counters, homing steer (via Angle.*/Query.nearest). Pooled/dense so thousands of bullets stay cache-friendly and deterministic (bullet-hell needs this).
  • Explosions/AoE via an Area trigger + Query.within on hit.
  • Events at every decision: cancellable ProjectileSpawned (mutable — modifiers/buffs adjust damage/count here), ProjectileHit {proj, target}, cancellable DamageAboutToApply (shared with #57), ProjectileExpired, Exploded.

Layer 4 — combat scaffolding (opt-in)

  • Health/armor/shields: shared Life/Stats from #57; Death/Hit/Heal events; hit-flash + knockback via events.
  • Faction/targeting: the shared faction table decides who a projectile/faction can hit; Query.nearest(faction, pos) for auto-aim and AI.
  • Pickups/ammo/weapon-swap: reuse the platformer Pickup + RPG Inventory if present (weapons as items).
  • Camera: Camera.follow + aim-lookahead (offset toward cursor) + Camera.shake on fire/hit (all exist in Camera.*).
  • Enemy spawner / wave director: property Spawner { pattern, rate, budget } + WaveStarted/Cleared events (shared with NPC AI #NPCAI — arena/horde modes).

How a developer extends each area

Want Lever How
New gun 1 data Weapon.def(...) — no code
New bullet pattern (spiral, aimed ring) 6 policy add a pattern emitter value, or @On(ProjectileSpawned) to spawn extras
Damage buffs / crit / lifesteal 4 event @On(DamageAboutToApply) mutate amount; @On(ProjectileHit)
Homing / boomerang / ricochet 1 data homing/bounce/pierce fields; deeper = own handler @After(esys_projectile)
Overheat / reload minigame 5 disable disable system esys_weapon, own firing handler; keep projectile system
Auto-aim assist 1 data aim_mode: 3 (nearest-enemy)
Tank controls 1 data turn_rate > 0, strafe: 0
Wave/horde rules 4 event @On(WaveCleared) / drive Spawner

Determinism / Ludic ties

  • Fixed-point integration + a fixed RNG stream for spread → bullet patterns replay exactly (deterministic bullet-hell is a genuine selling point; ties to Random.stream + input replay).
  • Dense projectile storage rides the ECS arrays; world_save/snapshot captures in-flight bullets → rollback netcode for co-op shooters.
  • Angle.* (auto-wrap), Vector/Query.nearest/Query.within, Camera.*, Cooldown all already exist.
  • The same Weapon/Projectile systems serve player and NPC (AI writes the "fire" intent instead of input) — no duplicate enemy-gun code.

Phasing

  1. TopDown mover + aim_mode (mouse/stick/move-dir) + facing/rotation. Depends on #57 collision.
  2. Weapon registry + esys_weapon (fire rate/ammo/reload/burst) + Projectile + esys_projectile (single-shot, TTL, collision, damage) + core events.
  3. Pierce/bounce/homing/gravity; pattern emitters (spread/ring/spiral) for bullet-hell.
  4. Combat scaffolding: faction targeting, auto-aim, camera shake/lookahead, pickups/weapon-swap.
  5. Spawner/wave director; examples/games/shooter.ludic (twin-stick arena) forkable template.

References

  • Godot top-down shooter demo; twin-stick design (Nuclear Throne, Enter the Gungeon).
  • Danmakufu / bullet-hell pattern emitters (ring/spiral/aimed).
  • Bevy bevy_rapier-style projectile pooling; SDL/Godot Area2D overlap for AoE.
_Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract. Planning only — no implementation._ ## Context A top-down shooter controller: a character that moves independently of where it aims (twin-stick / mouse-aim), a rotatable body, and a projectile system general enough for bullets, lasers, grenades, homing missiles, and bullet-hell patterns. Modeled on twin-stick classics (Nuclear Throne, Enter the Gungeon), Godot top-down demos, and bullet-hell frameworks (Danmakufu-style pattern emitters). Reuses #57 collision + faction; feeds directly into NPC AI (#NPCAI) enemies and RPG (#RPG) real-time combat. ## Layered design ### Layer 1 — the top-down mover + aim (data-first) Movement and facing are **decoupled** — the defining trait of the genre. ``` property TopDown { move_speed: int = 6, accel: int = 80, friction: int = 70, aim_mode: int = 0, # 0 mouse/point, 1 right-stick, 2 move-dir, 3 nearest-enemy (auto) turn_rate: int = 0, # 0 = instant snap; >0 = degrees/frame (tank-ish turning) strafe: int = 1, # 1 = body rotates freely of move dir; 0 = face move dir aim_angle: int = 0, # current facing, degrees (Angle.* auto-wraps) policy: int = 0 } model Gunner { Pos, Body, Collider, TopDown, Weapon, SpriteAnim } ``` - `aim_mode` covers the real control schemes as data: mouse-aim, right-stick, aim-where-you-move, and auto-aim (nearest enemy via `Query.nearest`) — the last one reused constantly by AI-controlled shooters. - `turn_rate` 0 = instant (arcade), >0 = gradual (uses `Angle.*` shortest-arc rotation) for heavier feel. - Sub-systems: `esys_topdown_move`, `esys_topdown_aim` (only the active `aim_mode` branch runs), `esys_topdown_facing`→sprite/rotation. ### Layer 2 — weapons (data-driven registry) A weapon is **data**, not a subclass — a registry entry like `Anim.clip`, so games/mods add guns with zero code: ``` Weapon.def("pistol", fire_rate: 8, damage: 10, speed: 20, spread: 2, pellets: 1, pattern: 0) Weapon.def("shotgun", fire_rate: 2, damage: 6, speed: 16, spread: 18, pellets: 8, pattern: 0) Weapon.def("laser", ... kind: beam ...) Weapon.def("wave", ... pattern: 3 ...) # bullet-hell arc property Weapon { def_id, ammo, mag, reload_frames, cooldown, heat } ``` - Fields cover the common axes: fire rate, damage, projectile speed, spread, pellet count, ammo/mag/reload, charge, heat/overheat, recoil. `kind` ∈ {projectile, hitscan/beam, melee-arc}. `pattern` selects an emitter (single, spread, ring, arc, spiral) — a `policy` for bullet-hell. - `esys_weapon` handles fire-rate `Cooldown`, ammo, reload, and burst/auto/charge fire modes; reads a `"fire"` input action (or an AI intent flag, so the *same* system drives player and NPC guns). ### Layer 3 — projectiles (the reusable core) ``` property Projectile { vx, vy, damage, ttl, pierce, bounce, homing, # 0 none; >0 = turn rate toward Target gravity, # 0 for top-down; >0 for lobbed grenades owner, faction, on_hit # on_hit policy: destroy/explode/stick/spawn } ``` - `esys_projectile` (phase Update): integrate, TTL countdown, collision vs. `Collider` layers/`faction`, pierce/bounce counters, homing steer (via `Angle.*`/`Query.nearest`). Pooled/dense so thousands of bullets stay cache-friendly and deterministic (bullet-hell needs this). - Explosions/AoE via an `Area` trigger + `Query.within` on hit. - Events at every decision: `cancellable ProjectileSpawned` (mutable — modifiers/buffs adjust damage/count here), `ProjectileHit {proj, target}`, `cancellable DamageAboutToApply` (shared with #57), `ProjectileExpired`, `Exploded`. ### Layer 4 — combat scaffolding (opt-in) - **Health/armor/shields**: shared `Life`/`Stats` from #57; `Death`/`Hit`/`Heal` events; hit-flash + knockback via events. - **Faction/targeting**: the shared faction table decides who a projectile/faction can hit; `Query.nearest(faction, pos)` for auto-aim and AI. - **Pickups/ammo/weapon-swap**: reuse the platformer `Pickup` + RPG `Inventory` if present (weapons as items). - **Camera**: `Camera.follow` + aim-lookahead (offset toward cursor) + `Camera.shake` on fire/hit (all exist in `Camera.*`). - **Enemy spawner / wave director**: `property Spawner { pattern, rate, budget }` + `WaveStarted/Cleared` events (shared with NPC AI #NPCAI — arena/horde modes). ## How a developer extends each area | Want | Lever | How | |---|---|---| | New gun | 1 data | `Weapon.def(...)` — no code | | New bullet pattern (spiral, aimed ring) | 6 policy | add a `pattern` emitter value, or `@On(ProjectileSpawned)` to spawn extras | | Damage buffs / crit / lifesteal | 4 event | `@On(DamageAboutToApply)` mutate amount; `@On(ProjectileHit)` | | Homing / boomerang / ricochet | 1 data | `homing`/`bounce`/`pierce` fields; deeper = own handler `@After(esys_projectile)` | | Overheat / reload minigame | 5 disable | `disable system esys_weapon`, own firing handler; keep projectile system | | Auto-aim assist | 1 data | `aim_mode: 3` (nearest-enemy) | | Tank controls | 1 data | `turn_rate > 0`, `strafe: 0` | | Wave/horde rules | 4 event | `@On(WaveCleared)` / drive `Spawner` | ## Determinism / Ludic ties - Fixed-point integration + a **fixed RNG stream** for spread → bullet patterns replay exactly (deterministic bullet-hell is a genuine selling point; ties to `Random.stream` + input replay). - Dense projectile storage rides the ECS arrays; `world_save`/snapshot captures in-flight bullets → rollback netcode for co-op shooters. - `Angle.*` (auto-wrap), `Vector`/`Query.nearest`/`Query.within`, `Camera.*`, `Cooldown` all already exist. - The **same** `Weapon`/`Projectile` systems serve player and NPC (AI writes the `"fire"` intent instead of input) — no duplicate enemy-gun code. ## Phasing 1. `TopDown` mover + `aim_mode` (mouse/stick/move-dir) + facing/rotation. Depends on #57 collision. 2. `Weapon` registry + `esys_weapon` (fire rate/ammo/reload/burst) + `Projectile` + `esys_projectile` (single-shot, TTL, collision, damage) + core events. 3. Pierce/bounce/homing/gravity; pattern emitters (spread/ring/spiral) for bullet-hell. 4. Combat scaffolding: faction targeting, auto-aim, camera shake/lookahead, pickups/weapon-swap. 5. Spawner/wave director; `examples/games/shooter.ludic` (twin-stick arena) forkable template. ## References - Godot top-down shooter demo; twin-stick design (Nuclear Throne, Enter the Gungeon). - Danmakufu / bullet-hell pattern emitters (ring/spiral/aimed). - Bevy `bevy_rapier`-style projectile pooling; SDL/Godot `Area2D` overlap for AoE.
Author
Owner

Amendment — ships as an external package

Per the decision on #57, this controller ships as an external, versioned source package (Ludic modules, compiled into the consumer's binary), not as compiler/runtime/native stdlib and not as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57.

Depends on #62 (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.

## Amendment — ships as an external package Per the decision on #57, this controller ships as an **external, versioned source package** (Ludic modules, compiled into the consumer's binary), **not** as compiler/`runtime/native` stdlib and **not** as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57. **Depends on #62** (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.
Author
Owner

Shipped — ludic.shooter (commit 02a8bcc)

A top-down shooter source package on the six-lever contract (#57), built on the engine Body/Collider and the ludic.gameplay Faction/Combat/Stats foundation.

  • TopDown decouples movement from aim — aim_mode: 0 mouse / 1 right-stick / 2 move-direction / 3 nearest-enemy auto-aim, with a turn_rate for tank-style gradual rotation (lever 1/6).
  • Weapons are a name-keyed registry (lever 1): Weapon.def("shotgun", fire_rate, damage, speed, spread, pellets, pattern) — add a gun with zero code — plus data-driven Weapon.set_pierce / Weapon.set_homing. Patterns: single / spread cone / ring / spiral (bullet-hell). esys_weapon reads a want_fire intent, so the same weapon fires for a player (input) and an NPC (#61 AI sets the flag) — no duplicate enemy-gun code.
  • Projectile + esys_projectile is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through Combat.damage, pierce, and homing that curves onto the nearest enemy. Every step is observable: cancellable/mutable ProjectileSpawned, ProjectileHit, ProjectileExpired.
  • Spawner — a budgeted, throttled wave director emitting SpawnRequested / WaveCleared (shared with NPC-AI arenas).

Fixed-point + fixed listener order → bullet patterns replay exactly. Example examples/games/shooter_demo.ludic — 11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill via Combat, ring, homing, wave spawner — a regression case in x test. Docs: docs/CONTROLLERS.md.

Closing as done.

## Shipped — `ludic.shooter` (commit 02a8bcc) A top-down shooter source package on the six-lever contract (#57), built on the engine `Body`/`Collider` and the `ludic.gameplay` Faction/Combat/Stats foundation. - **`TopDown`** decouples movement from aim — `aim_mode`: 0 mouse / 1 right-stick / 2 move-direction / 3 nearest-enemy auto-aim, with a `turn_rate` for tank-style gradual rotation (lever 1/6). - **Weapons are a name-keyed registry** (lever 1): `Weapon.def("shotgun", fire_rate, damage, speed, spread, pellets, pattern)` — add a gun with zero code — plus data-driven `Weapon.set_pierce` / `Weapon.set_homing`. Patterns: single / spread cone / ring / spiral (bullet-hell). `esys_weapon` reads a `want_fire` intent, so the **same** weapon fires for a player (input) and an NPC (#61 AI sets the flag) — no duplicate enemy-gun code. - **`Projectile` + `esys_projectile`** is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through `Combat.damage`, pierce, and homing that curves onto the nearest enemy. Every step is observable: `cancellable/mutable ProjectileSpawned`, `ProjectileHit`, `ProjectileExpired`. - **`Spawner`** — a budgeted, throttled wave director emitting `SpawnRequested` / `WaveCleared` (shared with NPC-AI arenas). Fixed-point + fixed listener order → bullet patterns replay exactly. Example `examples/games/shooter_demo.ludic` — 11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill via Combat, ring, homing, wave spawner — a regression case in `x test`. Docs: `docs/CONTROLLERS.md`. Closing as done.
orkun closed this issue 2026-09-01 16:28:43 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#60
No description provided.