Shooting Mechanics: Weapon Systems
Complete weapon systems with switching, ammo management, and best practices.
Learning Path: Basics → Advanced Aiming → You are here
Table of Contents
Weapon Switching
Use Case: Multiple weapon types
Switch between different weapons with unique properties.
Step 1: Define Weapon Types
Create weapon configurations:
type WeaponType = 'pistol' | 'shotgun' | 'laser';
const WEAPON_CONFIG = {
pistol: {
damage: 10,
cooldown: 300,
bulletSpeed: 500,
count: 1,
},
shotgun: {
damage: 5,
cooldown: 800,
bulletSpeed: 400,
count: 5,
spread: Math.PI / 4, // 45 degree spread
},
laser: {
damage: 20,
cooldown: 1500,
bulletSpeed: 800,
count: 1,
},
}; Step 2: Add Weapon to Player State
Track current weapon per player:
import { defineGame } from '@martini-kit/core';
export const game = defineGame({
setup: ({ playerIds }) => ({
players: Object.fromEntries(
playerIds.map((id) => [
id,
{
x: 400,
y: 300,
rotation: 0,
currentWeapon: 'pistol' as WeaponType,
},
])
),
bullets: [],
shootCooldowns: {},
nextBulletId: 0,
}),
// ... actions next
}); Step 3: Add Weapon Switch Action
Let players change weapons:
actions: {
switchWeapon: {
apply: (state, context, input: { weapon: WeaponType }) => {
const player = state.players[context.targetId];
if (!player) return;
player.currentWeapon = input.weapon;
},
},
// ... shoot action next
} Step 4: Shoot Based on Weapon Config
Use weapon properties when shooting:
shoot: {
apply: (state, context) => {
const player = state.players[context.targetId];
if (!player) return;
// Get current weapon config
const config = WEAPON_CONFIG[player.currentWeapon];
// Check cooldown
const cooldown = state.shootCooldowns[context.targetId] || 0;
if (cooldown > 0) return;
// Create bullets based on weapon
for (let i = 0; i < config.count; i++) {
let angle = player.rotation;
// Add spread for multi-bullet weapons (shotgun)
if (config.count > 1) {
const angleOffset =
((i - (config.count - 1) / 2) * config.spread) / (config.count - 1);
angle += angleOffset;
}
state.bullets.push({
id: state.nextBulletId++,
x: player.x,
y: player.y,
velocityX: Math.cos(angle) * config.bulletSpeed,
velocityY: Math.sin(angle) * config.bulletSpeed,
ownerId: context.targetId,
lifetime: 2000,
damage: config.damage, // Store damage on bullet
});
}
// Set weapon-specific cooldown
state.shootCooldowns[context.targetId] = config.cooldown;
},
}, Step 5: Display Current Weapon
Show weapon in HUD:
Using HUD Helper - Reactive weapon display:
import { PhaserAdapter, createPlayerHUD } from '@martini-kit/phaser';
create() {
this.adapter = new PhaserAdapter(runtime, this);
// Auto-updating weapon HUD
this.hud = createPlayerHUD(this.adapter, this, {
roleText: (myPlayer) => {
if (!myPlayer) return '';
return `Weapon: ${myPlayer.currentWeapon.toUpperCase()}`;
},
roleStyle: { fontSize: '16px', color: '#fff' },
layout: { role: { x: 10, y: 10 } }
});
// Weapon switch keys
this.input.keyboard!.on('keydown-ONE', () => {
this.runtime.submitAction('switchWeapon', { weapon: 'pistol' });
});
this.input.keyboard!.on('keydown-TWO', () => {
this.runtime.submitAction('switchWeapon', { weapon: 'shotgun' });
});
this.input.keyboard!.on('keydown-THREE', () => {
this.runtime.submitAction('switchWeapon', { weapon: 'laser' });
});
}What You’ve Built:
- ✅ Multiple weapons
- ✅ Weapon-specific properties
- ✅ Quick switching
- ✅ Different firing patterns
Enhancement Ideas:
- Add weapon pickup system
- Require reload when switching
- Show weapon stats in UI
- Add weapon animations
Ammo Management
Use Case: Limited ammunition
Track and manage ammunition with reloading.
Step 1: Add Ammo to Player State
Track ammo per player:
export const game = defineGame({
setup: ({ playerIds }) => ({
players: Object.fromEntries(
playerIds.map((id) => [
id,
{
x: 400,
y: 300,
rotation: 0,
ammo: 30,
maxAmmo: 30,
isReloading: false,
reloadTimer: 0,
},
])
),
bullets: [],
nextBulletId: 0,
}),
// ... actions next
}); Step 2: Check Ammo Before Shooting
Prevent shooting without ammo:
actions: {
shoot: {
apply: (state, context) => {
const player = state.players[context.targetId];
if (!player) return;
// Check ammo and reload status
if (player.ammo <= 0 || player.isReloading) {
console.log('No ammo or reloading');
return;
}
// Create bullet (same as before)
state.bullets.push({
id: state.nextBulletId++,
x: player.x,
y: player.y,
velocityX: Math.cos(player.rotation) * 400,
velocityY: Math.sin(player.rotation) * 400,
ownerId: context.targetId,
lifetime: 2000,
});
// Consume ammo
player.ammo--;
},
},
// ... reload action next
} Step 3: Add Reload Action
Start reload process:
reload: {
apply: (state, context) => {
const player = state.players[context.targetId];
if (!player) return;
// Start reload if not full and not already reloading
if (player.ammo < player.maxAmmo && !player.isReloading) {
player.isReloading = true;
player.reloadTimer = 1500; // 1.5 second reload
}
},
}, Step 4: Update Reload Timer
Complete reload in tick:
tick: {
apply: (state, context, input: { delta: number }) => {
// Update reload timers
for (const player of Object.values(state.players)) {
if (player.isReloading) {
player.reloadTimer -= input.delta;
// Complete reload when timer reaches 0
if (player.reloadTimer <= 0) {
player.ammo = player.maxAmmo;
player.isReloading = false;
player.reloadTimer = 0;
}
}
}
// ... update bullets ...
},
}, Step 5: Display Ammo UI
Show ammo count and reload progress:
Using HUD Helper - Reactive ammo display:
import { PhaserAdapter, createPlayerHUD } from '@martini-kit/phaser';
create() {
this.adapter = new PhaserAdapter(runtime, this);
// Auto-updating ammo HUD
this.hud = createPlayerHUD(this.adapter, this, {
roleText: (myPlayer) => {
if (!myPlayer) return '';
if (myPlayer.isReloading) {
const reloadPercent = (1 - myPlayer.reloadTimer / 1500) * 100;
return `RELOADING... ${Math.floor(reloadPercent)}%`;
}
return `Ammo: ${myPlayer.ammo}/${myPlayer.maxAmmo}`;
},
roleStyle: { fontSize: '18px', color: '#fff' },
layout: { role: { x: 10, y: 30 } }
});
// Reload key
this.input.keyboard!.on('keydown-R', () => {
this.runtime.submitAction('reload');
});
} Benefits:
- ✅ Reactive ammo counter
- ✅ Auto-updates on state change
- ✅ Cleaner code
What You’ve Built:
- ✅ Ammo tracking
- ✅ Reload mechanic
- ✅ Reload timer
- ✅ Visual feedback
Enhancement Ideas:
- Auto-reload when empty
- Different reload times per weapon
- Ammo pickups
- Reserve ammo pool
Best Practices
DO ✅
1. Use Cooldowns to Prevent Spam
// Always check cooldown before shooting
const cooldown = state.shootCooldowns[context.targetId] || 0;
if (cooldown > 0) return; 2. Track Bullet Owners for Damage Attribution
// Store who shot the bullet
state.bullets.push({
ownerId: context.targetId,
// ... other properties
}); 3. Set Lifetimes for Auto-Cleanup
// Bullets automatically expire
lifetime: 2000, // ms
// Clean up in tick
if (bullet.lifetime <= 0) {
state.bullets.splice(i, 1);
} 4. Use Unique IDs for Bullet Tracking
// Increment counter for unique IDs
id: state.nextBulletId++, 5. Normalize Aim Vectors for Consistent Speed
// Normalize direction vector
const dx = player.aimX - player.x;
const dy = player.aimY - player.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
velocityX: (dx / distance) * BULLET_SPEED,
velocityY: (dy / distance) * BULLET_SPEED,
} 6. Clean Up Expired Bullets in Tick
// Iterate backwards for safe removal
for (let i = state.bullets.length - 1; i >= 0; i--) {
if (shouldRemove(state.bullets[i])) {
state.bullets.splice(i, 1);
}
} 7. Use SpriteManager for Bullet Lifecycle
// Automatically handles creation/destruction
new SpriteManager(this.adapter, this, {
collection: 'bullets',
createSprite: (bullet) => { /* ... */ },
updateSprite: (sprite, bullet) => { /* ... */ },
}); DON’T ❌
1. Don’t Create Infinite Bullets
// ❌ BAD: Bullets never expire
state.bullets.push({
// ... no lifetime property
});
// ✅ GOOD: Always set lifetimes
state.bullets.push({
lifetime: 2000,
// ...
}); 2. Don’t Use Math.random() for Bullet Spread
// ❌ BAD: Non-deterministic, causes desyncs
const angle = player.rotation + Math.random() * 0.5;
// ✅ GOOD: Use context.random for deterministic randomness
const angle = player.rotation + context.random.next() * 0.5; 3. Don’t Forget Cooldowns
// ❌ BAD: Players can spam thousands of bullets
shoot: {
apply: (state, context) => {
state.bullets.push(/* ... */);
}
}
// ✅ GOOD: Always enforce cooldowns
shoot: {
apply: (state, context) => {
if ((state.shootCooldowns[context.targetId] || 0) > 0) return;
state.bullets.push(/* ... */);
state.shootCooldowns[context.targetId] = COOLDOWN;
}
} 4. Don’t Sync Bullets Individually
// ❌ BAD: Separate action per bullet
actions: {
addBullet: {
apply: (state, context, input: Bullet) => {
state.bullets.push(input);
}
}
}
// ✅ GOOD: Batch bullets in state
actions: {
shoot: {
apply: (state, context) => {
// Create bullets directly in state
state.bullets.push(/* ... */);
}
}
} 5. Don’t Create Bullets on Client
// ❌ BAD: Client creates bullets directly
this.bullets.push(/* ... */); // Client-side only!
// ✅ GOOD: Submit action, host creates bullet
this.runtime.submitAction('shoot'); // Synced to all clients Performance Tips
1. Object Pooling for Bullets
Instead of creating/destroying sprites constantly:
// Reuse sprite pool
this.bulletManager = new SpriteManager(this.adapter, this, {
collection: 'bullets',
createSprite: (bullet) => {
// SpriteManager handles pooling internally
return this.add.circle(0, 0, 4, 0xffff00);
},
}); 2. Limit Max Bullets
Prevent performance issues:
const MAX_BULLETS = 100;
shoot: {
apply: (state, context) => {
// Limit total bullets
if (state.bullets.length >= MAX_BULLETS) {
console.log('Max bullets reached');
return;
}
// ... create bullet
}
} 3. Spatial Partitioning for Collision
See Health and Damage for efficient collision detection.
Complete Example
Combining weapon switching and ammo:
type WeaponType = 'pistol' | 'shotgun';
const WEAPON_CONFIG = {
pistol: { damage: 10, cooldown: 300, ammo: 12, reloadTime: 1000 },
shotgun: { damage: 5, cooldown: 800, ammo: 6, reloadTime: 2000, count: 5 },
};
export const game = defineGame({
setup: ({ playerIds }) => ({
players: Object.fromEntries(
playerIds.map((id) => [
id,
{
x: 400,
y: 300,
rotation: 0,
currentWeapon: 'pistol' as WeaponType,
weaponAmmo: {
pistol: 12,
shotgun: 6,
},
isReloading: false,
reloadTimer: 0,
},
])
),
bullets: [],
shootCooldowns: {},
nextBulletId: 0,
}),
actions: {
shoot: {
apply: (state, context) => {
const player = state.players[context.targetId];
if (!player) return;
const config = WEAPON_CONFIG[player.currentWeapon];
// Check conditions
if (
player.isReloading ||
player.weaponAmmo[player.currentWeapon] <= 0 ||
(state.shootCooldowns[context.targetId] || 0) > 0
) {
return;
}
// Create bullets based on weapon
const count = config.count || 1;
for (let i = 0; i < count; i++) {
// ... create bullet
}
// Consume ammo and set cooldown
player.weaponAmmo[player.currentWeapon]--;
state.shootCooldowns[context.targetId] = config.cooldown;
},
},
reload: {
apply: (state, context) => {
const player = state.players[context.targetId];
if (!player) return;
const config = WEAPON_CONFIG[player.currentWeapon];
const currentAmmo = player.weaponAmmo[player.currentWeapon];
if (currentAmmo < config.ammo && !player.isReloading) {
player.isReloading = true;
player.reloadTimer = config.reloadTime;
}
},
},
tick: {
apply: (state, context, input: { delta: number }) => {
// Update cooldowns
for (const playerId of Object.keys(state.shootCooldowns)) {
state.shootCooldowns[playerId] = Math.max(
0,
state.shootCooldowns[playerId] - input.delta
);
}
// Update reloads
for (const player of Object.values(state.players)) {
if (player.isReloading) {
player.reloadTimer -= input.delta;
if (player.reloadTimer <= 0) {
const config = WEAPON_CONFIG[player.currentWeapon];
player.weaponAmmo[player.currentWeapon] = config.ammo;
player.isReloading = false;
player.reloadTimer = 0;
}
}
}
// ... update bullets
},
},
},
}); See Also
- Basics - Core shooting mechanics
- Advanced Aiming - Mouse aiming and patterns
- Player Movement - Movement and rotation
- Health and Damage - Bullet collision and damage
- Arena Blaster Example - Complete shooting game
- Phaser SpriteManager - Sprite lifecycle