Skip to main content

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​

modeWins a roundrules.scoreToWin range, defaultTeams
ffa free-for-allmost kills; a self-kill costs one3 to 50, 15no
tdm team deathmatchteam kills5 to 100, 30two
gun-gameevery 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 lengthno
ctf capture the flagbring 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 flagReturnSeconds1 to 10, 3two
koth king of the hillseconds spent holding the hill alone. Anyone else on it contests it and nobody scores20 to 300, 60two

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​

FieldTypeDefaultMeaning
idstringrequiredURL-safe, kebab-case. Also names the kills leaderboard.
titlestringrequiredThe game's name.
descriptionstringrequiredOne sentence for the game's card.
badgestringa target emojiCard metadata; the game never shows it.
modeShooterMode'ffa'One of the five modes.
rulesobjectsee belowScore, clock, rounds and per-mode rules.
mapobjectsee belowMap preset, size and cover.
playerobjectsee belowYour body, loadout, health and camera.
teamsobjectRed and BlueNames and colours of the two sides (team modes).
seatsnumber4 (ffa, gun-game), 6 (team modes)Humans plus bots, 2 to 8.
botsobjectsee belowBots in empty seats.
pickupsobject or falsesee belowItems on the map. false removes them all.
aimAssistAimAssistConfig or false{ coneDeg: 8, strength: 0.55 }Pulls your aim onto targets inside the cone. false turns it off. Fields in Combat.
multiplayerbooleantrueShows the invite button. Needs the game's backend.
theme{ accent?: string }'#6CF3FF'HUD accent and the game's accent.
soundsShooterSoundsnoneMusic and effects, see Sounds. Without it the match is silent.
hapticsbooleantrueVibrations for what happens to you. The player's haptics setting also turns them off.

rules​

FieldTypeDefaultMeaning
scoreToWinnumberper modePoints that win a round, range per mode (table above). Ignored in gun-game.
roundSecondsnumber300Round length, 60 to 1200. 0 means no clock.
roundsToWinnumber1Rounds to win the match, 1 to 5 (best of 2n minus 1).
countdownSecondsnumber3"3, 2, 1, GO" before each round, 0 to 10. Nobody moves or shoots.
respawnSecondsnumber3Seconds before the dead come back, 1 to 15.
friendlyFirebooleanfalseTeam-mates hurt each other.
ladderShooterWeapon[]pistol, smg, shotgun, rifle, rocket, sniper, swordGun game: the weapons from first to last, 2 to 12. Exported as DEFAULT_LADDER.
meleeDemotesbooleantrueGun game: a melee kill knocks the victim down one rung.
flagReturnSecondsnumber15CTF: seconds a dropped flag waits before going home, 3 to 60.
flagAtHomeToScorebooleantrueCTF: you can only score while your own flag is home.
hillRadiusnumber3.5KOTH: hill radius in metres, 2 to 8.
hillMoveSecondsnumber0 (never)KOTH: the hill moves every this many seconds. Any value other than 0 is clamped to 15 to 120.

map​

FieldTypeDefaultMeaning
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.
sizenumber22Half-size of the square in metres, 14 to 40. Small is fast and frantic; 30 and up suits snipers.
seednumber7Jitter for crate placement.
boxesLevelBox[][]Extra cover, as level boxes (see Character and cameras).
themeLevelThemeper presetLevel 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​

FieldTypeDefaultMeaning
colorCSS colour'#7C5CFF'Your body colour. Team modes use the team colour.
loadoutShooterWeapon[]['rifle', 'shotgun']1 to 3 weapons; SWAP cycles them. Ignored in gun game.
healthHealthConfig{ 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.
characterCharacterConfig{ 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​

FieldTypeDefaultMeaning
teams.names[string, string]['Red', 'Blue']The two sides' names.
teams.colors[string, string]['#FF5C7A', '#5CA8FF']The two sides' colours.
bots.fillbooleantrueBots 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.weaponsShooterWeapon[]['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​

FieldTypeDefaultMeaning
healthnumber50Hit points per health pack, 0 to 200. 0 means no health packs.
armornumber50Armour per pack, 0 to 200. 0 means none.
ammobooleantrueAmmo crates that refill the magazine.
weaponsShooterWeapon[]['rocket', 'sniper']Weapons lying on the map, up to 4. You keep one until you die. Always empty in gun game. [] means none.
respawnSecondsnumber20Seconds 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'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 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.

KeyPlaysHaptic
musicthe match theme, looping
shotevery shot: yours, and others' where they stand
hityour shot lands
hurtyou take a hitheavy
killyou take someone downsuccess
deathyou go downerror
explodea rocket or splash weapon going off (ducks the music)heavy, when it is near you
reloadyou reload
respawnyou are back in
pickupyou take an itemselect
roundStarta round's countdown starts
gothe countdown's GOtap
roundEnda round ends, or the match ends in a drawsuccess if your side took it, warning if another did
victoryyour side wins the matchsuccess
defeatanother side wins the matcherror
levelUpgun game: your next weaponsuccess
flagTaken, flagDropped, flagReturned, flagCapturedCTF: every flag eventsuccess when you take a flag, warning when yours is taken; on a capture, success for the scorers and warning for the others
hillTakenKOTH: your side holds the hill alonetap
hillLostKOTH: 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​

WhatHow
Mode and pacingmode, rules, seats.
Mapmap.preset, size, seed, boxes, theme.
Weaponsplayer.loadout, rules.ladder, bots.weapons, pickups.weapons, presets or your own WeaponConfig.
Difficultybots.difficulty, aimAssist, player.health, pickups.
Coloursplayer.color, teams.colors, map.theme, theme.accent.
Sounds and hapticssounds (keys above), haptics.
A rule no mode hasCopy 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, 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.