# Shooter module

> defineShooterGame builds a complete 3D arena shooter from one config, with five modes, rounds, three mirrored maps, items, bots in every empty seat, and multiplayer where one host phone runs the match.

`defineShooterGame(config)` makes a whole arena shooter: five game modes (`ffa`, `tdm`, `gun-game`, `ctf`, `koth`), rounds with a countdown and a clock, three mirrored maps, items on the map, bots in every empty seat, and multiplayer where friends join the same match. Use it for any gun game played in an arena: deathmatch, team fights, capture the flag, king of the hill, a weapon ladder.

```tsx
import { defineShooterGame } from '@/engine/modules/shooter';
import type { ShooterConfig, ShooterWeapon } from '@/engine/modules/shooter';
```

Source: `templates/crescent-game/src/engine/modules/shooter/`. It is built on the engine's [character controller and cameras](https://docs.moonspell.app/engine/character-and-cameras.md), [combat kit](https://docs.moonspell.app/engine/combat.md) and [bots](https://docs.moonspell.app/engine/bots.md).

## The smallest complete game

```tsx
// src/game/index.tsx
import { defineShooterGame } from '@/engine/modules/shooter';

export default defineShooterGame({
  id: 'yard-brawl',
  title: 'Yard Brawl',
  description: 'Everyone against everyone in an open courtyard. First to 15 wins.',
});
```

With every default, that is a free-for-all on the `hub` map: four seats (you and three bots), first to 15 kills or the most kills after five minutes, a rifle and a shotgun, health packs, armour, ammo crates and two weapons on the map.

A fuller one is `examples/flag-rush/index.tsx`, three against three capture the flag:

```tsx
import { defineShooterGame } from '@/engine/modules/shooter';

export default defineShooterGame({
  id: 'flag-rush',
  title: 'Flag Rush',
  description: 'Three against three capture the flag. Bring their flag home, first to three wins.',
  mode: 'ctf',
  rules: { scoreToWin: 3, roundSeconds: 420, respawnSeconds: 3 },
  map: { preset: 'depot', size: 24, seed: 5 },
  player: { color: '#FF5C7A', loadout: ['rifle', 'shotgun'], camera: 'third' },
  teams: { names: ['Ember', 'Tide'], colors: ['#FF5C7A', '#4FA8FF'] },
  seats: 6,
  bots: { difficulty: 'normal', weapons: ['rifle', 'smg', 'shotgun'] },
  pickups: { health: 50, armor: 50, ammo: true, weapons: ['rocket', 'sniper'] },
  theme: { accent: '#FFD166' },
});
```

## What the player gets

- A 3D arena with cover, an over-the-shoulder camera (or first person) and the engine's `CharacterControls`: a stick to move, the right half of the screen to look, **FIRE** (dragging it also turns the view, so one thumb aims and shoots), **JUMP**, and **SWAP** when the loadout has more than one weapon (never in gun game). In a desktop browser, WASD or the arrows move, Space fires, J, E or F jumps, K or Q swaps, and dragging the mouse on the look half turns the camera; see [Input](https://docs.moonspell.app/engine/input.md).
- A countdown before each round, a round clock, the score strip (team scores or the free-for-all table), the objective line, announcements, a killfeed, a crosshair, a hit marker, damage numbers, health, armour and ammo.
- Respawns after `respawnSeconds`, a round-over and match-over card with **Play again**.
- A camera button (third or first person), an invite button for multiplayer, and an exit button.

## Modes

| `mode` | Wins a round | `rules.scoreToWin` range, default | Teams |
| --- | --- | --- | --- |
| `ffa` free-for-all | most kills; a self-kill costs one | 3 to 50, 15 | no |
| `tdm` team deathmatch | team kills | 5 to 100, 30 | two |
| `gun-game` | every kill moves you up `rules.ladder`; a kill with the last weapon wins. A melee kill knocks the victim down a rung (`meleeDemotes`) | fixed: the ladder's length | no |
| `ctf` capture the flag | bring the enemy flag to your base, by default only while your own flag is home. A carrier who dies drops the flag; a team-mate touching it sends it home, or it goes home after `flagReturnSeconds` | 1 to 10, 3 | two |
| `koth` king of the hill | seconds spent holding the hill alone. Anyone else on it contests it and nobody scores | 20 to 300, 60 | two |

When the clock runs out, the leader wins the round; a tie is a drawn round.

## Config reference

Only `id`, `title` and `description` are required. Values outside a range are clamped.

### Top level

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `id` | `string` | required | URL-safe, kebab-case. Also names the kills leaderboard. |
| `title` | `string` | required | The game's name. |
| `description` | `string` | required | One sentence for the game's card. |
| `badge` | `string` | a target emoji | Card metadata; the game never shows it. |
| `mode` | `ShooterMode` | `'ffa'` | One of the five modes. |
| `rules` | object | see below | Score, clock, rounds and per-mode rules. |
| `map` | object | see below | Map preset, size and cover. |
| `player` | object | see below | Your body, loadout, health and camera. |
| `teams` | object | Red and Blue | Names and colours of the two sides (team modes). |
| `seats` | `number` | `4` (ffa, gun-game), `6` (team modes) | Humans plus bots, 2 to 8. |
| `bots` | object | see below | Bots in empty seats. |
| `pickups` | object or `false` | see below | Items on the map. `false` removes them all. |
| `aimAssist` | `AimAssistConfig` or `false` | `{ coneDeg: 8, strength: 0.55 }` | Pulls your aim onto targets inside the cone. `false` turns it off. Fields in [Combat](https://docs.moonspell.app/engine/combat.md). |
| `multiplayer` | `boolean` | `true` | Shows the invite button. Needs the game's backend. |
| `theme` | `{ accent?: string }` | `'#6CF3FF'` | HUD accent and the game's `accent`. |
| `sounds` | `ShooterSounds` | none | Music and effects, see [Sounds](https://docs.moonspell.app/modules/shooter.md#sounds). Without it the match is silent. |
| `haptics` | `boolean` | `true` | Vibrations for what happens to you. The player's haptics setting also turns them off. |

### `rules`

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `scoreToWin` | `number` | per mode | Points that win a round, range per mode (table above). Ignored in `gun-game`. |
| `roundSeconds` | `number` | `300` | Round length, 60 to 1200. `0` means no clock. |
| `roundsToWin` | `number` | `1` | Rounds to win the match, 1 to 5 (best of 2n minus 1). |
| `countdownSeconds` | `number` | `3` | "3, 2, 1, GO" before each round, 0 to 10. Nobody moves or shoots. |
| `respawnSeconds` | `number` | `3` | Seconds before the dead come back, 1 to 15. |
| `friendlyFire` | `boolean` | `false` | Team-mates hurt each other. |
| `ladder` | `ShooterWeapon[]` | pistol, smg, shotgun, rifle, rocket, sniper, sword | Gun game: the weapons from first to last, 2 to 12. Exported as `DEFAULT_LADDER`. |
| `meleeDemotes` | `boolean` | `true` | Gun game: a melee kill knocks the victim down one rung. |
| `flagReturnSeconds` | `number` | `15` | CTF: seconds a dropped flag waits before going home, 3 to 60. |
| `flagAtHomeToScore` | `boolean` | `true` | CTF: you can only score while your own flag is home. |
| `hillRadius` | `number` | `3.5` | KOTH: hill radius in metres, 2 to 8. |
| `hillMoveSeconds` | `number` | `0` (never) | KOTH: the hill moves every this many seconds. Any value other than 0 is clamped to 15 to 120. |

### `map`

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `preset` | `'hub' \| 'yard' \| 'depot'` | `'hub'` | `hub`: four pillars around an open centre, crates in the lanes. `yard`: an open courtyard cut by broken walls, long sight lines. `depot`: three lanes split by long walls, with crossings. |
| `size` | `number` | `22` | Half-size of the square in metres, 14 to 40. Small is fast and frantic; 30 and up suits snipers. |
| `seed` | `number` | `7` | Jitter for crate placement. |
| `boxes` | `LevelBox[]` | `[]` | Extra cover, as level boxes (see [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)). |
| `theme` | `LevelTheme` | per preset | Level colours: `sky`, `floor`, `wall`, `block`, `platform`, `fog`. |

Every preset is mirrored through the centre: red spawns and flag at negative Z, blue at positive Z, the first hill in the middle, items in mirrored pairs. The module's tests check that bots can walk to every spawn, base, hill and item on every preset and size. If you add `boxes`, keep that true: walls about 0.6 m thick, crates no taller than about 1.6 m, nothing on a base, the centre or an item spot.

### `player`

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `color` | CSS colour | `'#7C5CFF'` | Your body colour. Team modes use the team colour. |
| `loadout` | `ShooterWeapon[]` | `['rifle', 'shotgun']` | 1 to 3 weapons; SWAP cycles them. Ignored in gun game. |
| `health` | `HealthConfig` | `{ max: 100, regenPerSecond: 10, regenDelay: 4 }` | Hit points, armour and regeneration. Fields in [Combat](https://docs.moonspell.app/engine/combat.md). |
| `camera` | `'third' \| 'first'` | `'third'` | The starting camera. The camera button switches it. |
| `character` | `CharacterConfig` | `{ runSpeed: 6.8, walkSpeed: 3.2, jumpHeight: 1.3 }` | Movement. Fields in [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md). |

A `ShooterWeapon` is a preset name (`'pistol'`, `'rifle'`, `'smg'`, `'shotgun'`, `'sniper'`, `'blaster'`, `'rocket'`, `'grenade'`, `'sword'`) or a full `WeaponConfig`. Start a custom weapon from a preset:

```tsx
import { WEAPON_PRESETS } from '@/engine';
import type { ShooterWeapon } from '@/engine/modules/shooter';

const paintGun: ShooterWeapon = { ...WEAPON_PRESETS.smg, name: 'Paint gun', color: '#FF5CF0', damage: 34, magazine: 20 };
```

### `teams`, `seats` and `bots`

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `teams.names` | `[string, string]` | `['Red', 'Blue']` | The two sides' names. |
| `teams.colors` | `[string, string]` | `['#FF5C7A', '#5CA8FF']` | The two sides' colours. |
| `bots.fill` | `boolean` | `true` | Bots take every empty seat. A joining friend takes a bot's seat; a leaving one gets a bot back. |
| `bots.difficulty` | `'easy' \| 'normal' \| 'hard'` | `'normal'` | Aim error, reaction time and bursts. |
| `bots.weapons` | `ShooterWeapon[]` | `['rifle', 'shotgun', 'smg', 'blaster']` | Handed out to bots in turn, 1 to 8. |

Bots play the mode. In CTF, half of each team attacks the enemy flag and the rest guards their own, chases a carrier or returns a dropped flag; a bot with the flag runs it home. In KOTH they go for the hill. They fight whatever they see on the way.

### `pickups`

| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `health` | `number` | `50` | Hit points per health pack, 0 to 200. `0` means no health packs. |
| `armor` | `number` | `50` | Armour per pack, 0 to 200. `0` means none. |
| `ammo` | `boolean` | `true` | Ammo crates that refill the magazine. |
| `weapons` | `ShooterWeapon[]` | `['rocket', 'sniper']` | Weapons lying on the map, up to 4. You keep one until you die. Always empty in gun game. `[]` means none. |
| `respawnSeconds` | `number` | `20` | Seconds before a taken item comes back, 5 to 90. |

## Multiplayer

With the game's backend deployed (`GAME_BACKEND_URL` set) and `multiplayer` on, the invite button opens a room and shares it. Friends who join take a bot's seat in the same match. There is no backend code to write: the backend scaffold's rooms relay the messages.

- **One host.** The player who has been in the room longest is the host. Every phone works this out the same way from the room's player list, so there is no vote. The host runs the whole match: bots, every hit, scores, flags, the hill, items and the clock. It sends a snapshot about 15 times a second (`SNAPSHOT_HZ`).
- **Guests** move their own body on their own phone, so there is no input lag, and send their position, trigger and aim. The host fires their shots, so damage, kills and captures are decided in one place. Guests draw everyone else from the snapshots.
- **The host leaves:** the next oldest player takes over from the last snapshot and the match carries on.
- **Without a backend**, or before anyone joins, the phone is the host of a match with bots.

Limits: up to 8 seats. Knockback does not push guests. The host is a phone, so the match pauses for everyone while the host's app is in the background.

Inside the Moonspell app the invite shares the game's link with the room in it. Outside it there is no share link, so the invite shares the room code as text; the engine joins a room when the game is opened with a `room` parameter in its URL (`useLaunchRoomId`, see [Networking](https://docs.moonspell.app/engine/net.md)). Joining from outside the Moonspell app is untested.

### Backend needs

- **Rooms:** the scaffold's room as shipped (it seats up to 8).
- **Leaderboard:** a winner's kills are submitted to the board `<id>-kills`. Add it to the backend's `BOARDS`, for example `"flag-rush-kills": { order: "desc", min: 0, max: 500 }`. Without the entry, scores stay on the phone.

See the [backend overview](https://docs.moonspell.app/backend/overview.md) and [Realtime](https://docs.moonspell.app/backend/realtime.md).

## Sounds

Every key of `sounds` is optional and takes a `require`d file. A missing key is silent, and with no `sounds` at all the module opens no audio. They play on the engine's mixer: shots and explosions are placed in the world (louder near you, panned to their side), the rest play in your ears. The haptics in the last column fire whether or not the sound is set, unless `haptics` is `false`.

| Key | Plays | Haptic |
| --- | --- | --- |
| `music` | the match theme, looping | |
| `shot` | every shot: yours, and others' where they stand | |
| `hit` | your shot lands | |
| `hurt` | you take a hit | heavy |
| `kill` | you take someone down | success |
| `death` | you go down | error |
| `explode` | a rocket or splash weapon going off (ducks the music) | heavy, when it is near you |
| `reload` | you reload | |
| `respawn` | you are back in | |
| `pickup` | you take an item | select |
| `roundStart` | a round's countdown starts | |
| `go` | the countdown's GO | tap |
| `roundEnd` | a round ends, or the match ends in a draw | success if your side took it, warning if another did |
| `victory` | your side wins the match | success |
| `defeat` | another side wins the match | error |
| `levelUp` | gun game: your next weapon | success |
| `flagTaken`, `flagDropped`, `flagReturned`, `flagCaptured` | CTF: every flag event | success when you take a flag, warning when yours is taken; on a capture, success for the scorers and warning for the others |
| `hillTaken` | KOTH: your side holds the hill alone | tap |
| `hillLost` | KOTH: another side holds it | |

```tsx
sounds: {
  music: require('../../assets/audio/arena.m4a'),
  shot: require('../../assets/sfx/arena/shot.wav'),
  hit: require('../../assets/sfx/arena/hit.wav'),
  kill: require('../../assets/sfx/arena/kill.wav'),
},
```

In multiplayer, guests hear the same cues. The host sends no events, so a guest works them out from the snapshots: its own hits, kills, hurts and deaths, the phase, the flags, the hill, new tracers and explosions.

## What you can customise

| What | How |
| --- | --- |
| Mode and pacing | `mode`, `rules`, `seats`. |
| Map | `map.preset`, `size`, `seed`, `boxes`, `theme`. |
| Weapons | `player.loadout`, `rules.ladder`, `bots.weapons`, `pickups.weapons`, presets or your own `WeaponConfig`. |
| Difficulty | `bots.difficulty`, `aimAssist`, `player.health`, `pickups`. |
| Colours | `player.color`, `teams.colors`, `map.theme`, `theme.accent`. |
| Sounds and haptics | `sounds` (keys above), `haptics`. |
| A rule no mode has | Copy `src/engine/modules/shooter/ShooterGame.tsx` into `src/game/`, fix its relative imports to `@/engine` paths, and add the rule next to the match. Or build on `examples/pulse-arena/` with the combat kit directly. |

The match itself is plain TypeScript, usable without React: `ShooterMatch` (`match.ts`) has `step(dt)`, `trigger(...)`, `swap`, `addHuman`, `removeHuman`, `standings()`, `drainEvents()` (round start, go, round end, match end, flag, hill, level-up, pickup) and `drainCombatEvents()` (shots, hits, kills). `ShooterCues` (`cues.ts`) turns both into the sounds and haptics above: call `collect(match, events, combatEvents)` once a frame. Also exported: `resolveShooterConfig`, `buildShooterMap`, `buildPickups`, `applyPickup`, `stepPickups`, `encodeSnapshot`, `applySnapshot`, `ShooterNet`, `pickHost`, the scene pieces `CombatantMeshes` and `ObjectiveMeshes`, and the HUD pieces `ScoreStrip`, `AnnouncementBanner` and `ObjectiveLine`.

## Dev and testing

`npm test` runs `shooter.test.ts`: config defaults, map reachability for every preset and size, every mode, rounds, items, sounds and haptics (host and guest), snapshots and the host handover.

In development builds the HUD shows a telemetry line (`hud-debug`: mode, phase, round, clock, health, kills and deaths, shots and hits, weapon, scores, bot behaviours, net role) and an **AUTO** button (`btn-autopilot`) that hunts the nearest enemy, plays the flag or the hill and fires on lock.

Other `testID`s: `hud-countdown`, `hud-score` (team modes), `hud-scoreboard` (free-for-all), `hud-objective`, `hud-announce`, `hud-eliminated`, `hud-room`, `btn-invite`, `btn-camera`, `btn-offline`, `btn-exit`, and `game-<id>` on the root view.

## Gotchas

- In `gun-game`, `scoreToWin` and `pickups.weapons` are ignored: the ladder decides both.
- `loadout` is cut to 3 weapons and `pickups.weapons` to 4.
- `rules.hillMoveSeconds: 5` becomes 15. Only `0` turns moving off.
- Team colours override `player.color` in team modes.
- Large `boxes` can block bots' paths to a flag, hill or item. Keep the rules under `map` above.
