diff --git a/docs/PROJECT_LOG.md b/docs/PROJECT_LOG.md
index 0b2d422..5a12cd9 100644
--- a/docs/PROJECT_LOG.md
+++ b/docs/PROJECT_LOG.md
@@ -69,6 +69,11 @@ Payments/monetization P0s are separately listed in `docs/MVP_STATUS_AND_DEV_PLAN
## Log
+### 2026-10-01 — DRIVE passenger identity and local reward guard (#201, partial)
+
+- The legacy DRIVE mission now receives a snapshot of the confirmed four-seat crew, exposes the selected **active shooter passenger** in its HUD, rechecks eligibility before starting and at firing time, tags player bullets with that member ID, and credits kills/XP only to named passengers whose bullets cause a hit. The driver is excluded even when shooter-trained; duplicate, malformed and stale seats cannot fire. A run ID lets each local retry/replay book once without accepting a callback from a prior run. A valid in-flight shot keeps its credit if a member later becomes unavailable.
+- Red→green participant and reward regressions cover shooter-driver vs passenger, stale/injured and duplicate seats, switching, named XP, repeated completion, retries, and post-shot roster changes. The actual demo DRIVE path was replayed at 375×812, 390×844 and 1440×900 with the status-rail overlap corrected. **Still open on #201:** shots are not gated or parameterized by a recorded weapon/ammo inventory; same-frame finalization may read stale stats; and mounted-component duplicate protection is not a durable authoritative reward receipt across reloads. This slice is not outside-beta readiness or the authenticated two-user proof (#175).
+
### 2026-09-30 — Drive-by car loadout: true roles, portrait-first HUD, original responsive art (#197)
- The DRIVE app's four-seat selector now limits drivers to active dealers/recruits/shooters/dedicated drivers; only active shooters occupy passenger seats. Seat assignments are rechecked at launch, including stale member roles, injuries, absence and duplicates. The legacy first-person mini-game does not yet bind individual shots to a named passenger (#201), although the driver is excluded from shooter XP.
diff --git a/frontend/src/components/driveby/DriveByEngine.tsx b/frontend/src/components/driveby/DriveByEngine.tsx
index 44e57ce..49492c5 100644
--- a/frontend/src/components/driveby/DriveByEngine.tsx
+++ b/frontend/src/components/driveby/DriveByEngine.tsx
@@ -30,6 +30,9 @@ import {
shouldTriggerBulletCam,
} from '../../utils/bulletCamTrigger';
import '../combat/CombatTacticalHud.css';
+import { useGangStore } from '../../stores/gameStore';
+import type { CarCrew } from './CarCrewSelector';
+import { availablePassengerShooters } from './driveByParticipants';
// ============ TYPES ============
type TargetType = 'gang' | 'civilian' | 'leader';
@@ -55,6 +58,7 @@ interface Target {
interface Bullet {
id: number;
+ shooterId: string | null;
startX: number;
startY: number;
x: number;
@@ -86,6 +90,7 @@ interface Block {
interface GameStats {
kills: number;
+ killsByShooter: Record;
civilianHits: number;
accuracy: number;
shotsHit: number;
@@ -129,14 +134,24 @@ const preloadImages = (urls: string[]): Promise
// ============ MAIN COMPONENT ============
const DriveByEngine: React.FC<{
onExit?: () => void;
- onComplete?: (stats: GameStats) => void;
+ onRunStart?: (runId: number) => void;
+ onComplete?: (stats: GameStats, runId: number) => void;
/** Structured target from CarCrewSelector — seeds the procedural street */
targetBlock?: DriveByTarget | null;
+ /** Confirmed seats from the DRIVE app; the driver never fires. */
+ crew?: CarCrew | null;
}> = ({
onExit,
+ onRunStart,
onComplete,
targetBlock,
+ crew,
}) => {
+ const members = useGangStore(state => state.members);
+ const validShooters = availablePassengerShooters(crew, members);
+ const [selectedShooterId, setSelectedShooterId] = useState(null);
+ const activeShooterId = selectedShooterId ?? validShooters[0]?.id ?? null;
+ const activeShooter = validShooters.find(member => member.id === activeShooterId);
const [gameState, setGameState] = useState<'menu' | 'playing' | 'paused' | 'gameover' | 'victory'>('menu');
const [score, setScore] = useState(0);
const [heat, setHeat] = useState(0);
@@ -148,7 +163,7 @@ const DriveByEngine: React.FC<{
const [bullets, setBullets] = useState([]);
const [crosshair, setCrosshair] = useState({ x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 });
const [stats, setStats] = useState({
- kills: 0, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0
+ kills: 0, killsByShooter: {}, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0
});
const [combatFeed, setCombatFeed] = useState([]);
const [hitMarker, setHitMarker] = useState(null);
@@ -157,6 +172,7 @@ const DriveByEngine: React.FC<{
const canvasRef = useRef(null);
const gameLoopRef = useRef(0);
+ const runIdRef = useRef(0);
const lastShotRef = useRef(0);
const blockPosRef = useRef(0);
const parallaxRef = useRef({ bg: 0, mid: 0, fg: 0 });
@@ -276,6 +292,11 @@ const DriveByEngine: React.FC<{
// ============ GAME INITIALIZATION ============
const initGame = useCallback(() => {
+ if (!activeShooterId || !availablePassengerShooters(crew, useGangStore.getState().members)
+ .some(member => member.id === activeShooterId)) return;
+ const runId = ++runIdRef.current;
+ onRunStart?.(runId);
+ setSelectedShooterId(activeShooterId);
setScore(0);
setHeat(0);
setCarHealth(100);
@@ -296,7 +317,7 @@ const DriveByEngine: React.FC<{
bulletCamActiveRef.current = false;
lastBulletCamAtRef.current = 0;
setBulletCamShot(null);
- setStats({ kills: 0, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0 });
+ setStats({ kills: 0, killsByShooter: {}, civilianHits: 0, accuracy: 0, shotsHit: 0, shotsFired: 0, blocksCleared: 0, moneyEarned: 0 });
// Reset window state so retries start with a fresh intact window
const freshWindow = createWindowState(true);
setWindowState(freshWindow);
@@ -331,7 +352,7 @@ const DriveByEngine: React.FC<{
setBlocks(newBlocks);
setGameState('playing');
- }, []);
+ }, [activeShooterId, crew, onRunStart]);
// ============ SPAWN TARGETS ============
const spawnTargets = useCallback((block: Block) => {
@@ -385,6 +406,9 @@ const DriveByEngine: React.FC<{
// ============ SHOOTING ============
const shoot = useCallback((x: number, y: number) => {
if (gameState !== 'playing' || ammo <= 0 || bulletCamActiveRef.current) return;
+ // Check at click time as the roster may change after mission selection.
+ if (!activeShooterId || !availablePassengerShooters(crew, useGangStore.getState().members)
+ .some(member => member.id === activeShooterId)) return;
// Sprint 17: cannot fire through raised/moving glass.
if (!canShoot(windowRef.current)) {
playSound('miss');
@@ -410,6 +434,7 @@ const DriveByEngine: React.FC<{
setBullets(prev => [...prev, {
id: now,
+ shooterId: activeShooterId,
startX: GAME_WIDTH * 0.7,
startY: GAME_HEIGHT * 0.75,
x: GAME_WIDTH * 0.7,
@@ -419,7 +444,7 @@ const DriveByEngine: React.FC<{
speed: BULLET_SPEED,
isPlayer: true
}]);
- }, [gameState, ammo, addParticles, triggerShake]);
+ }, [gameState, ammo, addParticles, triggerShake, activeShooterId, crew]);
// ============ AUDIO ============
const audioCtxRef = useRef(null);
@@ -636,7 +661,7 @@ const DriveByEngine: React.FC<{
lastBulletCamAtRef.current = now;
setBulletCamShot({
shotId: `${bullet.id}:${target.id}`,
- shooterId: 'driveby-player',
+ shooterId: bullet.shooterId ?? 'unknown-passenger',
startX: bullet.startX,
startY: bullet.startY,
targetX: bullet.targetX,
@@ -660,6 +685,7 @@ const DriveByEngine: React.FC<{
// ============ GAME LOOP ============
useEffect(() => {
if (gameState !== 'playing') return;
+ const runId = runIdRef.current;
const canvas = canvasRef.current;
if (!canvas) return;
@@ -723,7 +749,7 @@ const DriveByEngine: React.FC<{
if (nextBlock >= blocks.length) {
setGameState('victory');
- if (onComplete) onComplete(stats);
+ if (onComplete) onComplete(stats, runId);
return;
}
@@ -754,6 +780,7 @@ const DriveByEngine: React.FC<{
setBullets(prev => [...prev, {
id: now + target.id,
+ shooterId: null,
startX: target.x + target.width / 2,
startY: target.y + target.height / 2,
x: target.x + target.width / 2,
@@ -833,6 +860,9 @@ const DriveByEngine: React.FC<{
setStats(s => ({
...s,
kills: s.kills + 1,
+ killsByShooter: bullet.shooterId
+ ? { ...s.killsByShooter, [bullet.shooterId]: (s.killsByShooter[bullet.shooterId] ?? 0) + 1 }
+ : s.killsByShooter,
shotsHit: s.shotsHit + 1,
moneyEarned: s.moneyEarned + target.bounty
}));
@@ -869,7 +899,7 @@ const DriveByEngine: React.FC<{
const newHealth = h - Math.max(1, Math.round(10 * mult));
if (newHealth <= 0) {
setGameState('gameover');
- if (onComplete) onComplete(stats);
+ if (onComplete) onComplete(stats, runId);
}
playSound('damage');
triggerShake(5);
@@ -898,7 +928,7 @@ const DriveByEngine: React.FC<{
// Check raid threshold
if (heat >= HEAT_RAID_THRESHOLD) {
setGameState('gameover');
- if (onComplete) onComplete(stats);
+ if (onComplete) onComplete(stats, runId);
return;
}
@@ -1269,6 +1299,17 @@ const DriveByEngine: React.FC<{
/>
)}
+ {(gameState === 'menu' || gameState === 'playing') && crew && (
+
+ Current shooter: {activeShooter?.name ?? 'Unavailable'}
+ {validShooters.map(member => )}
+
+ )}
+
{/* Sprint 17: touch control for the window — [2] on desktop */}
{gameState === 'playing' && !windowState.shattered && (
2 Lower the glass or you cannot shoot
-