Shooter module
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.
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, combat kit and bots.
The smallest complete game
// 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:
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. - 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. |
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. 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). |
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. |
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. |
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:
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). 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'sBOARDS, for example"flag-rush-kills": { order: "desc", min: 0, max: 500 }. Without the entry, scores stay on the phone.
See the backend overview and Realtime.
Sounds
Every key of sounds is optional and takes a required 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 |
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 testIDs: 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,scoreToWinandpickups.weaponsare ignored: the ladder decides both. loadoutis cut to 3 weapons andpickups.weaponsto 4.rules.hillMoveSeconds: 5becomes 15. Only0turns moving off.- Team colours override
player.colorin team modes. - Large
boxescan block bots' paths to a flag, hill or item. Keep the rules undermapabove.