This document outlines a comprehensive plan for implementing the vent system in the Agentic Among Us simulation. Venting is a critical impostor-only mechanic that allows:
- Quick traversal across the map through connected vent networks
- Escape routes after kills
- Stealth positioning for ambushes
Critical Social Dynamic: Anyone who sees a player enter or exit a vent immediately knows they are an impostor. This creates:
- High-risk/high-reward gameplay
- Potential for false accusations ("I saw X vent!")
- Lying opportunities for manipulative agents
- Entry Time: 0.3 seconds
- Exit Time: 0.3 seconds
- Travel Time: Instant between connected vents
- Vision in Vents: Only see vent UI, no external vision
- Movement in Vents: Click arrows or connected vent icons
- Detection: Vent animation visible for 0.3s on entry/exit
- Cooldown: ~2 seconds between vent uses
- West Network: Upper Engine ↔ Lower Engine ↔ Reactor
- Central Network: MedBay ↔ Electrical ↔ Security
- East Network: Navigation ↔ Weapons ↔ Shields
- Cafeteria Network: Cafeteria ↔ Admin
- Must be an Impostor
- Must be within vent interaction range (±0.3 units in original, ~10px in our scale)
- Must not be currently in a task or kill animation
Status: Partially exists (Vent interface exists, Player has isInVent and currentVent)
Need to add:
// Vent state tracking
export interface VentState {
ventId: string;
occupantId: string | null;
isAnimating: boolean;
animationType: 'entry' | 'exit' | null;
animationStartTime: number | null;
}
// Vent event for tracking observations
export interface VentEvent {
id: string;
timestamp: number;
playerId: string;
playerName: string;
ventId: string;
ventRoom: string;
eventType: 'ENTER' | 'EXIT' | 'TRAVEL';
destinationVentId?: string; // For TRAVEL events
witnesses: Array<{
id: string;
name: string;
distance: number;
}>;
}Files: shared/types/game.types.ts
Add to simulation.types.ts:
export type AIGoalType =
// ... existing types ...
| 'ENTER_VENT' // Go to nearest accessible vent and enter
| 'EXIT_VENT' // Exit current vent at chosen destination
| 'VENT_TO' // Enter vent and travel to specific destinationFiles: shared/types/simulation.types.ts, server/src/ai/prompts/AgentPrompts.ts
New file: shared/engine/VentSystem.ts
export interface VentSystemConfig {
entryTime: number; // 0.3 seconds
exitTime: number; // 0.3 seconds
cooldownTime: number; // 2.0 seconds
interactionRange: number; // 0.5 units
visibilityDetectionRange: number; // How far witnesses can see vent usage
}
export class VentSystem {
private ventStates: Map<string, VentState>;
private ventEvents: VentEvent[];
private playerCooldowns: Map<string, number>;
// Core methods
canEnterVent(playerId: string, ventId: string): VentValidation;
canExitVent(playerId: string, destinationVentId: string): VentValidation;
enterVent(playerId: string, ventId: string): VentEvent | null;
exitVent(playerId: string, destinationVentId: string): VentEvent | null;
travelInVent(playerId: string, fromVentId: string, toVentId: string): boolean;
// Query methods
getConnectedVents(ventId: string): Vent[];
getNearbyVents(position: Point, range: number): Vent[];
getPlayersInVents(): Map<string, string>; // playerId -> ventId
// Witness detection
getWitnessesForVentEvent(ventPosition: Point, excludePlayerId: string): Witness[];
}Enhance shared/data/skeld-map-accurate.ts:
// Already exists: getAllVentsAccurate(), getVentById()
// Need to add:
export function getVentNetwork(ventId: string): Vent[];
export function getVentsByRoom(roomId: string): Vent[];
export function getNearestVent(position: Point): Vent | null;
export function canTravelBetween(fromVentId: string, toVentId: string): boolean;Files: server/src/simulation/SimulationManager.ts
- Initialize VentSystem with map data
- Add vent request callback to AIAgent (similar to kill callback)
- Process vent events each tick
- Broadcast vent events to clients
// In SimulationManager
private ventSystem: VentSystem;
private handleVentRequest(playerId: string, action: 'enter' | 'exit', ventId: string): boolean {
// Validate and execute vent action
// Return success/failure
}Files: shared/types/simulation.types.ts
export interface AIContext {
// ... existing fields ...
// Vent context (impostor only)
ventContext?: {
isInVent: boolean;
currentVentId: string | null;
connectedVents: Array<{
id: string;
room: string;
distance: number; // Travel distance if exiting there
witnessRisk: number; // 0-100, based on nearby players
}>;
nearbyVents: Array<{
id: string;
room: string;
distance: number;
canEnter: boolean; // Within range
witnessRisk: number;
}>;
ventCooldownRemaining: number;
};
}Files: server/src/ai/prompts/AgentPrompts.ts, server/src/ai/AIDecisionService.ts
Add impostor-specific prompts for vent usage:
// In buildSystemPrompt for impostors:
VENT MECHANICS (Impostor Only):
- You can use vents to travel quickly between connected locations
- WARNING: Anyone who sees you enter/exit a vent KNOWS you're the impostor!
- Current vent network connections: [show connected vents]
- Use vents strategically for:
- Escaping after kills
- Quick positioning
- Ambush setups
- Available vent actions: ENTER_VENT, EXIT_VENT, VENT_TOIn VentSystem:
getWitnessesForVentEvent(
ventPosition: Point,
excludePlayerId: string,
allPlayers: Map<string, AIAgent>
): Witness[] {
const witnesses: Witness[] = [];
allPlayers.forEach((agent, playerId) => {
if (playerId === excludePlayerId) return;
if (agent.getPlayerState() !== 'ALIVE') return;
const distance = calculateDistance(agent.getPosition(), ventPosition);
if (distance <= agent.getVisionRadius()) {
// Check line of sight
if (hasLineOfSight(agent.getPosition(), ventPosition)) {
witnesses.push({
id: playerId,
name: agent.getName(),
distance
});
}
}
});
return witnesses;
}Files: shared/engine/AgentMemory.ts
// Add new KnownInfoType
VENT_SEEN: 'VENT_SEEN' // Already exists in game.types.ts!
// In AgentMemory, add method:
recordVentSighting(
suspectId: string,
suspectName: string,
ventId: string,
ventRoom: string,
eventType: 'ENTER' | 'EXIT',
timestamp: number
): void {
// This is CONCLUSIVE evidence of impostor!
// Set suspicion to 100% immediately
this.setSuspicionLevel(suspectId, 100);
// Record the observation
this.observations.push({
type: 'VENT_SEEN',
subjectId: suspectId,
location: ventRoom,
timestamp,
details: `Saw ${suspectName} ${eventType.toLowerCase()} vent in ${ventRoom}`
});
}Files: shared/engine/AIAgent.ts
// Add vent request callback type
export type VentRequestCallback = (
playerId: string,
action: 'enter' | 'exit',
ventId: string
) => boolean;
// In AIAgent class:
private ventRequestCallback?: VentRequestCallback;
setVentRequestCallback(callback: VentRequestCallback): void {
this.ventRequestCallback = callback;
}
// Add vent action methods:
private async enterVent(ventId: string): Promise<boolean>;
private async exitVent(destinationVentId: string): Promise<boolean>;
private async ventTo(destinationVentId: string): Promise<boolean>;
// In executeDecision():
case 'ENTER_VENT':
await this.enterVent(decision.targetVentId);
break;
case 'EXIT_VENT':
await this.exitVent(decision.targetVentId);
break;
case 'VENT_TO':
await this.ventTo(decision.targetVentId);
break;Files: shared/engine/AIAgent.ts
// New thought triggers
export type ThoughtTrigger =
// ... existing ...
| 'near_vent' // Impostor near accessible vent
| 'saw_vent_event' // Witnessed someone use a vent
| 'entered_vent' // Just entered a vent
| 'in_vent_deciding' // In vent, deciding where to exit
| 'exited_vent' // Just exited a vent
// Trigger when impostor approaches vent
private checkVentProximityTrigger(): ThoughtTrigger | null {
if (this.aiState.role !== 'IMPOSTOR') return null;
if (this.aiState.isInVent) return null;
const nearbyVent = this.getNearestVent();
if (nearbyVent && nearbyVent.distance < 2.0) {
return 'near_vent';
}
return null;
}Files: server/src/ai/AIDecisionService.ts
When building prompts for impostors:
// Include vent information in context
if (context.role === 'IMPOSTOR') {
const ventInfo = this.buildVentContextInfo(context);
userPrompt += `\n\nVENT OPTIONS:\n${ventInfo}`;
}
private buildVentContextInfo(context: AIContext): string {
if (context.ventContext?.isInVent) {
return `You are INSIDE a vent at ${context.ventContext.currentVentId}.
Connected vents you can exit to:
${context.ventContext.connectedVents.map(v =>
`- ${v.room}: Witness risk ${v.witnessRisk}%`
).join('\n')}
Choose wisely - exiting near witnesses will expose you!`;
} else {
return `Nearby vents:
${context.ventContext?.nearbyVents.map(v =>
`- ${v.room} (${v.distance.toFixed(1)} units away): ${v.canEnter ? 'Accessible' : 'Too far'}, Witness risk: ${v.witnessRisk}%`
).join('\n')}`;
}
}Files: server/src/ai/prompts/AgentPrompts.ts
// Update regex to include vent actions
const goalMatch = response.match(
/GOAL:\s*(GO_TO_TASK|WANDER|...|ENTER_VENT|EXIT_VENT|VENT_TO)/i
);
// Parse vent target
const ventTargetMatch = response.match(/VENT_TARGET:\s*(\w+)/i);
if (goalType === 'ENTER_VENT' || goalType === 'EXIT_VENT' || goalType === 'VENT_TO') {
decision.targetVentId = ventTargetMatch?.[1];
}Files: src/rendering/ObjectRenderer.ts
class VentSprite {
// Add animation state
private state: 'closed' | 'opening' | 'open' | 'closing' = 'closed';
private animationProgress: number = 0;
private occupantId: string | null = null;
// Animate vent grate opening/closing
setAnimationState(state: 'opening' | 'closing', occupantId?: string): void;
// Draw method updates:
private draw(): void {
// Draw grate based on animation progress
// 0 = closed (grate visible)
// 1 = open (grate slid aside)
if (this.animationProgress > 0) {
// Draw open vent (dark hole)
this.drawOpenVent();
}
this.drawGrate(1 - this.animationProgress); // Slide grate
// Highlight if impostor nearby (for debug/spectator view)
if (this.showHighlight) {
this.drawHighlight();
}
}
}Files: src/rendering/PlayerRenderer.ts
class PlayerSprite {
// Add vent animation state
private ventAnimationType: 'entering' | 'exiting' | null = null;
private ventAnimationProgress: number = 0;
private ventPosition: Point | null = null;
// Start vent animation
startVentAnimation(type: 'entering' | 'exiting', ventPosition: Point): void {
this.ventAnimationType = type;
this.ventAnimationProgress = 0;
this.ventPosition = ventPosition;
}
// In animate():
if (this.ventAnimationType) {
this.animateVentTransition(deltaTime);
}
private animateVentTransition(deltaTime: number): void {
const VENT_ANIM_DURATION = 0.3; // 300ms
this.ventAnimationProgress += deltaTime / VENT_ANIM_DURATION;
if (this.ventAnimationProgress >= 1) {
this.ventAnimationType = null;
this.ventAnimationProgress = 0;
return;
}
if (this.ventAnimationType === 'entering') {
// Shrink and move toward vent center
const scale = 1 - this.ventAnimationProgress;
this.container.scale.set(scale);
// Move toward vent
const targetX = this.ventPosition!.x * this.scale;
const targetY = this.ventPosition!.y * this.scale;
this.container.x += (targetX - this.container.x) * 0.2;
this.container.y += (targetY - this.container.y) * 0.2;
} else {
// Grow from vent center
const scale = this.ventAnimationProgress;
this.container.scale.set(scale);
}
}
}Files: src/rendering/PlayerRenderer.ts, src/rendering/GameRenderer.ts
// In PlayerRenderer.update():
update(player: PlayerSnapshot): void {
// ... existing code ...
// Handle vent visibility
if (player.isInVent) {
// Player is completely hidden when in vent
this.container.visible = false;
} else {
this.container.visible = true;
}
}
// In GameRenderer - track vent animations
private handleVentEvent(event: VentEvent): void {
const playerSprite = this.playerRenderer.getSprite(event.playerId);
const ventSprite = this.objectRenderer.getVentSprite(event.ventId);
if (event.eventType === 'ENTER') {
playerSprite?.startVentAnimation('entering', event.ventPosition);
ventSprite?.setAnimationState('opening');
// After animation, hide player
setTimeout(() => {
ventSprite?.setAnimationState('closing');
}, 300);
} else if (event.eventType === 'EXIT') {
ventSprite?.setAnimationState('opening');
playerSprite?.startVentAnimation('exiting', event.ventPosition);
setTimeout(() => {
ventSprite?.setAnimationState('closing');
}, 300);
}
}Files: src/rendering/GameRenderer.ts, public/audio/
// Add vent sound
private ventSound: HTMLAudioElement;
// In constructor:
this.ventSound = new Audio('/audio/vent.mp3');
this.ventSound.volume = 0.4;
// Play on vent events
private playVentSound(): void {
// Only play if within hearing range (similar to kill sound)
this.ventSound.currentTime = 0;
this.ventSound.play().catch(() => {});
}Audio file needed: Create/source a metallic vent opening sound (~0.3s)
Files: shared/types/protocol.types.ts
// Add vent event to world snapshot
export interface WorldSnapshot {
// ... existing fields ...
ventEvents: VentEventSnapshot[]; // Recent vent events for this tick
playersInVents: string[]; // Player IDs currently in vents
}
export interface VentEventSnapshot {
id: string;
timestamp: number;
playerId: string;
playerName: string;
ventId: string;
ventRoom: string;
eventType: 'ENTER' | 'EXIT';
destinationVentId?: string;
// Don't send witnesses to client - calculated locally
}
// WebSocket message types
export type ServerMessage =
| { type: 'world_update', data: WorldSnapshot }
| { type: 'vent_event', data: VentEventSnapshot } // Real-time vent notification
// ... other typesFiles: shared/engine/serialization.ts
// Add vent state to player snapshot
export interface PlayerSnapshot {
// ... existing fields ...
isInVent: boolean;
currentVentId: string | null;
ventAnimationState?: 'entering' | 'exiting' | null;
}
// Serialize vent events
export function serializeVentEvent(event: VentEvent): VentEventSnapshot {
return {
id: event.id,
timestamp: event.timestamp,
playerId: event.playerId,
playerName: event.playerName,
ventId: event.ventId,
ventRoom: event.ventRoom,
eventType: event.eventType,
destinationVentId: event.destinationVentId
};
}Files: agents.md
Move vent system from "Not Yet Implemented" to "Fully Implemented" section:
### ✅ Fully Implemented
| System | Details |
|--------|---------|
| **Vent System** | Impostor-only vent travel, entry/exit animations, witness detection, AI decision support |
### AI Decision Types (Active)
**Impostor-Only Goals:**KILL - Eliminate a crewmate
HUNT - Actively seek isolated targets
ENTER_VENT - Enter nearest accessible vent
EXIT_VENT - Exit vent at chosen destination
VENT_TO - Navigate through vent network to destination
...
### Thought Triggers (Active)
near_vent - IMPOSTOR ONLY: Near accessible vent saw_vent_event - Witnessed someone use a vent (instant 100% suspicion!) entered_vent - Just entered a vent in_vent_deciding - Inside vent, choosing exit exited_vent - Just emerged from vent
- Types and interfaces
- AI goal types
- VentSystem class
- Data helper functions
- SimulationManager integration
- AIContext vent data
- AI prompts
- Witness detection
- Memory events
- AIAgent vent actions
- Thought triggers
- Prompt strategy
- Response parsing
- VentSprite animations
- Player vent animations
- Visibility handling
- Sound effects
- Protocol updates
- Serialization
- Documentation
- Impostor can enter vent when in range
- Impostor cannot enter vent from too far
- Crewmates cannot enter vents
- Player disappears when in vent
- Player can travel between connected vents
- Player cannot travel to non-connected vents
- Vent cooldown prevents spam
- Witnesses within vision see vent usage
- Witnesses outside vision don't see vent usage
- Witness sets suspicion to 100%
- AI chooses appropriate vent strategies
- Vent animations play correctly
- Vent sound plays on usage
- Client receives vent events via WebSocket
- Player position updates correctly on vent exit
Players can claim "I saw X vent!" when they didn't. This creates:
- Social deception opportunities
- He-said-she-said dynamics
- Trust/credibility systems needed
Mitigation: Track observation authenticity in memory. If agent claims to see vent but wasn't in vision range, they're lying.
Impostors could stay in vents indefinitely to avoid detection.
Mitigation: Add a maximum vent time (e.g., 30 seconds), or make AI evaluate when to exit.
Client and server vent animations could get out of sync.
Mitigation: Use server timestamps for all vent events, client interpolates.
- ✅ Vision system (for witness detection)
- ✅ Line-of-sight calculations
- ✅ Pathfinding (for distance to vent calculations)
- ✅ AI decision framework
- ✅ WebSocket protocol
- ⬜ Vent sound effect audio file
Document created: [Current Date] Last updated: [Current Date]