A new README for the combined project with setup from the Flashpoint Ultimate torrent. New feature docs for streaming, proxy sign-in and game saves; deployment, upgrade and streaming READMEs updated for the single compose file; the project renamed throughout.
51 KiB
Common Pitfalls
Known issues and solutions for Dotcom Arcade development.
Environment Setup Issues
Flashpoint Path Not Found
Symptom:
Error: ENOENT: no such file or directory, open 'D:/Flashpoint/Data/flashpoint.sqlite'
Solution:
# Verify installation exists
ls D:/Flashpoint/Data/flashpoint.sqlite # Linux/Mac
dir D:\Flashpoint\Data\flashpoint.sqlite # Windows
# Create .env from example
cd backend && cp .env.example .env
# Edit .env with correct path
FLASHPOINT_PATH=D:/Flashpoint # Windows forward slashes
JWT_SECRET=your-secure-secret
# Verify
ls $FLASHPOINT_PATH/Data/flashpoint.sqlite
Node Version Mismatch
Symptom:
Error: The engine "node" is incompatible with this module
Expected version ">=20.0.0". Got "18.12.0"
Solution:
node --version # Check current version
# Install Node 20+ with nvm
nvm install 20
nvm use 20
# Or download from nodejs.org
Dependencies Won't Install
Symptom:
npm ERR! ERESOLVE unable to resolve dependency tree
Solution:
npm cache clean --force
npm run clean # Remove all node_modules
npm run install:all
npm run install:all -- --legacy-peer-deps # If still fails
Database Issues
Database Locked Error
Symptom:
SqliteError: database is locked
Solution:
# Close Flashpoint Launcher (it locks flashpoint.sqlite)
# Check for open processes
lsof backend/user.db # Linux/Mac
# Use Process Explorer on Windows
# Kill processes
kill -9 <PID>
# If persistent, restart development server
npm run dev
Prevention:
class DatabaseService {
private static instance: DatabaseService;
private db: Database;
private constructor() {
this.db = new Database('user.db');
}
public static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
public close(): void {
this.db.close();
}
}
Migration Fails
Symptom:
Error: Migration failed: table users already exists
Solution:
# Check current schema
sqlite3 backend/user.db ".schema"
# Development: Delete and recreate
rm backend/user.db
npm run dev # Migrations run automatically
# Production: Rollback manually
sqlite3 backend/user.db "DROP TABLE IF EXISTS users;"
npm run dev
Read-Only Database Error
Symptom:
Error: attempt to write a readonly database
Causes:
- Writing to flashpoint.sqlite (read-only, managed by Flashpoint Launcher)
- Incorrect permissions on user.db
Solution:
# NEVER write to flashpoint.sqlite
# Check permissions
ls -l backend/user.db
# Fix if needed
chmod 644 backend/user.db
# In code: Use correct database
const userDb = UserDatabaseService.getInstance();
userDb.createUser(...); # ✓ Correct
Port Conflicts
Port Already in Use
Symptom:
Error: listen EADDRINUSE: address already in use :::3100
Solution:
# Find process using port
lsof -i :3100 # Linux/Mac
netstat -ano | findstr 3100 # Windows
# Kill process
kill -9 <PID> # Linux/Mac
taskkill /PID <PID> /F # Windows
# Or use backend utility
cd backend && npm run kill-port
# Change port in .env if needed
PORT=3002
Authentication Issues
JWT Token Expired
Symptom:
401 Unauthorized: Token expired
Solution:
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
try {
// Refresh token sent automatically via HTTP-only cookie
const { data } = await api.post('/api/auth/refresh', {});
// Store new access token in memory (Zustand store)
useAuthStore.getState().updateAccessToken(data.accessToken);
error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
return api(error.config);
} catch {
useAuthStore.getState().clearAuth();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
CORS Errors
Symptom:
Access to XMLHttpRequest blocked by CORS policy
Solution:
// Backend
import cors from 'cors';
app.use(cors({
origin: process.env.DOMAIN || 'http://localhost:5173',
credentials: true,
exposedHeaders: ['X-Total-Count'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Frontend - use authenticated api instance
import { api } from '@/lib/api';
const { data } = await api.get('/api/games'); # ✓ Has auth headers
Game Loading Issues
Game Files Not Loading
Symptom:
- Blank game screen or 404 errors
Solution:
# Verify backend is running
curl http://localhost:3100/api/health
# Check logs
npm run dev:backend
# Verify Flashpoint paths
ls $FLASHPOINT_PATH/Legacy/htdocs
ls $FLASHPOINT_PATH/Data/Games/
# Test game proxy access
curl http://localhost:3100/game-proxy/file.swf
Game Download Polling Issues
Symptom:
- Download status doesn't update, stuck showing "Downloading..."
- Download completes but UI doesn't reflect it
Root Causes:
- Polling not activated: TanStack Query's
refetchIntervalonly works if condition is checked properly - Wrong response checking: Backend returns
downloading: falsebut frontend still polls - Query cache not updated: Old cached value persists after download
Solution:
// CORRECT: Conditional polling based on data state
const { data: launchData } = useQuery({
queryKey: ['game', id, 'launch'],
queryFn: () => gamesApi.getGameLaunchData(id),
refetchInterval: (query) => {
// Poll every 2 seconds if downloading
return query.state.data?.downloading ? 2000 : false;
},
});
// WRONG: Static polling
const { data: launchData } = useQuery({
queryKey: ['game', id, 'launch'],
queryFn: () => gamesApi.getGameLaunchData(id),
refetchInterval: 2000, // ❌ Polls forever!
});
// WRONG: Not checking response
if (launchData?.downloading) {
// Show downloading UI
} else if (launchData) {
// Show player - but might have cached old response
}
Prevention:
- Always check
query.state.data?.downloadingin refetchInterval callback - Use query cache properly - don't manually track state
- Test by monitoring network tab for polling requests
- Verify responses show
downloading: falsewhen complete
Ruffle Not Loading
Symptom:
Error: Ruffle is not defined
Solution:
# Verify files exist
ls frontend/public/ruffle/
# Copy if missing
cd frontend && npm run copy-ruffle
# Verify version
cat package.json | grep ruffle
# Update if needed
npm install @ruffle-rs/ruffle@latest
npm run copy-ruffle
# Clear browser cache (Ctrl+Shift+Delete)
Game Service Resource Management Issues
Fire-and-Forget Mount Pattern
Symptom:
- Race conditions when game starts before ZIP is mounted
- Requests fail with "ZIP not mounted" errors
- Intermittent game loading failures
Root Cause:
Backend calls mountGameZip() but doesn't await it:
// WRONG: Fire-and-forget
this.gameZipServer.mountGameZip(gameId, zipPath, dateAdded, sha256);
// Returns immediately, but mount hasn't completed yet!
return { mounted: true };
Solution:
Always await the mount operation:
// CORRECT: Wait for mount to complete
const result = await this.gameZipServer.mountGameZip(
gameId,
zipPath,
dateAdded,
sha256
);
if (!result.success) {
throw new Error(`Failed to mount ZIP: ${result.error}`);
}
return { mounted: true };
Prevention:
- Review all mount operations - ensure they're awaited
- Test game launch immediately after mount (no delays)
- Check server logs for "mount timeout" errors
node-stream-zip Memory Usage with Large Files
Symptom:
- High memory consumption when accessing large files in ZIPs
- Application crashes with "out of memory" errors
- Slow performance when extracting file data
Root Cause:
entryData() reads entire file into memory Buffer:
// WRONG: Reads entire file into memory
const data = zip.entryData(filename);
// If file is 500MB, consumes 500MB+ RAM!
Solution:
Check file size before reading and stream large files:
// CORRECT: Check size first
const entry = zip.entries()[filename];
if (!entry) {
throw new Error(`File not found: ${filename}`);
}
const MAX_BUFFER_SIZE = 100 * 1024 * 1024; // 100MB limit
if (entry.size > MAX_BUFFER_SIZE) {
// Stream large files instead
return zip.createReadStream(filename);
} else {
// Safe to buffer small files
return zip.entryData(filename);
}
Prevention:
- Document file size limits for ZIP access
- Monitor memory usage in production
- Set reasonable buffer limits per deployment environment
- Test with various ZIP sizes
LRU Cache File Handle Accumulation
Symptom:
- "Too many open files" errors after many game loads
- File descriptor exhaustion
- System runs out of available file handles
Root Cause:
LRU cache's dispose callback is async but eviction is sync:
// WRONG: Async cleanup on sync eviction
this.cache.set(cacheKey, zipManager, {
dispose: async (zipManager) => {
// This is async...
await zipManager.close(); // ...but eviction won't wait!
}
});
// zipManager.close() fires but doesn't complete
// File handle stays open
// Multiple instances accumulate
Solution:
Use synchronous cleanup or properly track promises:
// CORRECT: Synchronous cleanup
const closePromises = new Map();
this.cache.set(cacheKey, zipManager, {
dispose: (zipManager) => {
// Close synchronously if possible
if (zipManager.close) {
zipManager.close(); // Synchronous close
}
}
});
// OR: Track async closures separately
async handleCacheEviction() {
const pendingClosures = Array.from(closePromises.values());
await Promise.all(pendingClosures);
}
Prevention:
- Implement synchronous close methods where possible
- Monitor file descriptor count:
lsof -p <PID> | wc -l - Set appropriate cache size limits
- Manually evict unused entries during maintenance
- Add metrics for open file handles
Frontend Build Issues
Build Fails with Type Errors
Symptom:
ERROR: Type error: Property 'xyz' does not exist on type 'ABC'
Solution:
cd frontend
# Type check first
npm run typecheck
# Fix errors in reported files
# Restart TypeScript server in VS Code
# Ctrl+Shift+P > "Restart TypeScript Server"
Vite HMR Not Working
Symptom:
- Changes not reflecting in browser, manual refresh needed
Solution:
# Verify dev server running
curl http://localhost:5173
# Check browser console for HMR errors
# Clear Vite cache
rm -rf frontend/node_modules/.vite
# Restart dev server
cd frontend && npm run dev
# On Linux, increase file watcher limits
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
Tailwind Styles Not Applied
Symptom:
- Tailwind classes not working
Solution:
# Verify tailwind.config.js includes all source files
# content should match './src/**/*.{js,ts,jsx,tsx}'
# Ensure Tailwind directives in src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
# Import CSS in main.tsx
import './index.css';
# Clear build cache and rebuild
rm -rf frontend/dist frontend/node_modules/.vite
npm run dev
Performance Issues
Multiple COUNT Queries Instead of Scalar Subqueries
Symptom:
- Slow paginated responses with separate COUNT queries
- Multiple roundtrips to database for pagination results
- Example:
/api/users?page=1makes 2 queries (SELECT + COUNT)
Root Cause:
Separate queries for data and total count:
// WRONG: 2 queries (inefficient)
const users = db.all('SELECT * FROM users LIMIT 50 OFFSET 0');
const total = db.get('SELECT COUNT(*) as total FROM users');
return { users, total: total.total };
CORRECT - Merge with window function or scalar subquery:
// CORRECT: 1 query with window function
const result = db.all(
`SELECT u.*,
COUNT(*) OVER() as total_count
FROM users u
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`,
[limit, offset]
);
const total = result.length > 0 ? result[0].total_count : 0;
const users = result.map(u => { delete u.total_count; return u; });
// OR: Scalar subquery
const result = db.all(
`SELECT u.*,
(SELECT COUNT(*) FROM users) as total_count
FROM users u
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`,
[limit, offset]
);
Prevention:
- Always fetch count in the same query as data
- Use window functions (
COUNT(*) OVER()) for paginated queries - Use scalar subqueries for single column aggregates
- Profile queries:
EXPLAIN QUERY PLANshould show one main scan, not two
O(N²) Data Merging with .find() in Loops
Symptom:
- Slow API responses when returning joined data (favorites with game details, playlists with games)
- Response time degrades proportionally with O(N²)
- Example: Getting 100 favorites with 1000 total games is 100,000 comparisons
Root Cause:
Using .find() loop to match related data:
// WRONG: O(N²) complexity
const favoriteGames = favorites.map(fav => {
const game = games.find(g => g.id === fav.gameId); // Searches through entire games array
return { ...fav, game };
});
CORRECT - Use Map for O(1) lookups:
// CORRECT: O(N) with Map
const gamesMap = new Map(games.map(g => [g.id, g])); // Build index once
const favoriteGames = favorites.map(fav => {
const game = gamesMap.get(fav.gameId); // O(1) lookup
return { ...fav, game };
});
Prevention:
- Build a
Mapwhen merging data from two lists - Never use
.find()inside loops over potentially large datasets - Profile response times: should be linear O(N+M), not quadratic O(N*M)
Directory Scans Instead of Direct File Access
Symptom:
- File operations are slow (playlists, user data files)
- Speed degrades as file count increases
- All filesystem lookups take O(N) time
Root Cause:
Always scanning the directory instead of trying direct path first:
// WRONG: Always scans directory
async getPlaylist(id: string) {
const files = await fs.readdir(playlistsPath);
for (const file of files) {
const content = await fs.readFile(path.join(playlistsPath, file));
const playlist = JSON.parse(content);
if (playlist.id === id) return playlist;
}
}
CORRECT - Try direct path first:
// CORRECT: O(1) fast path, O(N) fallback
async getPlaylist(id: string) {
// Try direct path first (most common case)
try {
const content = await fs.readFile(
path.join(playlistsPath, `${id}.json`)
);
return JSON.parse(content);
} catch (e) {
// Fallback: scan only if direct access fails
const files = await fs.readdir(playlistsPath);
for (const file of files) {
if (file === `${id}.json`) continue; // Already tried
const content = await fs.readFile(path.join(playlistsPath, file));
const playlist = JSON.parse(content);
if (playlist.id === id) return playlist;
}
}
return null;
}
Prevention:
- For file-based data, assume filename matches ID
- Try direct file access before falling back to directory scan
- Use extracted helper methods (e.g.,
findPlaylistFile()) to avoid code duplication - Use
Promise.allSettled()for parallel file reads instead of sequential loops
Slow Database Queries
Symptom:
- API responses taking >1s
Solution:
-- Add indexes
CREATE INDEX idx_game_platform ON game(platform);
CREATE INDEX idx_game_library ON game(library);
-- Analyze query performance
EXPLAIN QUERY PLAN SELECT * FROM game WHERE platform = 'Flash';
-- Prefer prefix search (uses index)
SELECT * FROM game WHERE title LIKE 'mario%'; # Better
SELECT * FROM game WHERE title LIKE '%mario%'; # Full scan
Frontend Memory Leaks
Symptom:
- High memory usage, slow interactions
Solutions:
// 1. Virtualize long lists
import { useVirtualizer } from '@tanstack/react-virtual';
// 2. Memoize expensive components
export const GameCard = memo(({ game }: GameCardProps) => <div>...</div>);
// 3. Use pagination instead of infinite scroll
const { data, fetchNextPage } = useInfiniteQuery({ ... });
// 4. Lazy load images
<img src={game.thumbnail} loading="lazy" alt={game.title} />
// 5. Code splitting
const GamePlayer = lazy(() => import('./GamePlayer'));
<Suspense fallback={<Loading />}><GamePlayer /></Suspense>
Code Quality Issues
Duplicate Utility Functions
Symptom:
Confusion about which utility to import. Inconsistent behavior across codebase.
Root Cause:
Similar utility functions exist in multiple files:
// WRONG: Same logic in multiple files
// frontend/src/lib/utils/errorUtils.ts
export function getErrorMessage(error: unknown): string { ... }
// frontend/src/lib/api/api-error.ts
export function getErrorMessage(error: unknown): string { ... }
// Which one should components import?
CORRECT - Single source of truth:
// CORRECT: One centralized utility
// frontend/src/lib/utils/errorUtils.ts
export function getErrorMessage(error: unknown): string { ... }
// frontend/src/lib/api/api-error.ts
import { getErrorMessage } from '../utils/errorUtils';
export { getErrorMessage }; // Re-export if needed for compatibility
Prevention:
- Search for duplicate function names before creating new utilities
- Consolidate similar functions into a single module
- Document which utilities are canonical in team conventions
- Use linters to detect duplicate code
parseInt without NaN Validation
Symptom:
API returns 404 for numeric IDs instead of 400 Bad Request. Logs show NaN passed to queries.
Root Cause:
parseInt() returns NaN for non-numeric strings, which silently passes to SQL
queries and matches no rows:
// WRONG: No NaN check
const id = parseInt(req.params.id, 10);
const user = UserDatabaseService.get('SELECT * FROM users WHERE id = ?', [id]);
// If req.params.id is 'abc', id is NaN
// Query runs: WHERE id = NaN (matches nothing)
// User gets 404 instead of 400
CORRECT - Always check isNaN():
// CORRECT: Validate after parsing
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid ID' });
}
const user = UserDatabaseService.get('SELECT * FROM users WHERE id = ?', [id]);
Why this matters:
- NaN passes silently to SQL, creating misleading 404 responses
- Makes debugging harder - looks like data doesn't exist
- Security issue: No validation feedback helps attackers probe
- Client sees confusing "not found" instead of "invalid format"
Prevention:
- Always check
isNaN()afterparseInt() - Unit test with non-numeric IDs
- Review logs for NaN values in queries
Unbounded Query Limits
Symptom:
Memory spikes and database slowdowns. Clients request limit=999999 and dump
entire tables.
Root Cause:
Query limits not capped:
// WRONG: No limit cap
const limit = req.query.limit ? parseInt(req.query.limit) : 10;
const games = GameService.getGames(limit);
// Client requests: /api/games?limit=999999
// Server executes: SELECT * FROM game LIMIT 999999
// Returns entire database contents
CORRECT - Cap query limits:
// CORRECT: Cap limit with Math.min
const limit = Math.min(
req.query.limit ? parseInt(req.query.limit) : 10,
100 // Maximum 100 results
);
const games = GameService.getGames(limit);
Recommended Caps:
- Games/favorites/users: 100 per request
- Playlists: 50 per request
- Logs: 50 per request
Prevention:
- Add
Math.min()for all user-provided limits - Document API limit in OpenAPI/Swagger specs
- Monitor slow queries in production logs
- Test with extreme values:
?limit=999999
Server-Sent Events (SSE) Error Handling
Symptom:
Backend crashes when errors occur during SSE streaming. Error: "Cannot set headers after they are sent to the client."
Root Cause:
Once SSE headers are sent, response.status().json() fails because headers are
already in the response:
// WRONG: Tries to set status after headers sent
const sendGameStream = (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' }); // Headers sent
res.write(`data: ${JSON.stringify(data)}\n\n`);
try {
// Some operation
} catch (error) {
res.status(500).json({ error: 'Server error' }); // ❌ Crashes!
}
};
CORRECT - Check headersSent before responding:
// CORRECT: Check if headers already sent
const sendGameStream = (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write(`data: ${JSON.stringify(data)}\n\n`);
try {
// Some operation
} catch (error) {
if (res.headersSent) {
// Headers already sent, use SSE format for error
res.write(`data: {"error": "Server error"}\n\n`);
res.end();
} else {
// Headers not sent, use normal JSON response
res.status(500).json({ error: 'Server error' });
}
}
};
Prevention:
- Always check
res.headersSentbefore callingres.status() - Wrap error handling around entire response lifecycle
- Use appropriate error format for SSE (events, not JSON)
- Test error paths during development
PerformanceMetrics Path Normalization
Symptom:
Metrics memory grows unbounded. /api/games/:id with different UUIDs creates
separate entries for each unique game.
Root Cause:
Parametric routes create unbounded map entries if paths aren't normalized:
// WRONG: Records raw request paths
GET /api/games/550e8400-e29b-41d4-a716-446655440000 // Separate entry
GET /api/games/6ba7b810-9dad-11d1-80b4-00c04fd430c8 // Separate entry
GET /api/games/6ba7b811-9dad-11d1-80b4-00c04fd430c8 // Separate entry
// After 10k unique games, metrics map has 10k entries!
CORRECT - Normalize paths before recording:
// CORRECT: Replace UUIDs/numbers with :id placeholder
const normalizePath = (path: string): string => {
return path
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id') // UUID
.replace(/\/\d+/g, '/:id'); // Numeric ID
};
// Usage in metrics middleware
const normalizedPath = normalizePath(req.path);
recordMetric(normalizedPath, duration);
Prevention:
- Always normalize parametric segments before recording metrics
- Monitor metrics map size:
metrics.sizeshould equal number of unique routes - Set
maxMapSizewith warnings if exceeded - Use middleware to normalize paths consistently
Cache Clearing Logic with Overlapping Conditions
Symptom:
Clearing one specific cache type also clears other caches unexpectedly.
Root Cause:
Overlapping if blocks instead of mutually exclusive branching:
// WRONG: Overlapping if blocks
if (!cacheType || cacheType === 'gameSearch') { clearGameSearch(); }
if (!cacheType || cacheType === 'permissions') { clearPermissions(); }
// When cacheType is 'gameSearch', the first block runs but so does... nothing else.
// But when cacheType is undefined, BOTH blocks run (double clear).
// And unknown types silently do nothing.
CORRECT - Use switch for mutual exclusivity:
switch (cacheType) {
case 'gameSearch':
clearGameSearch();
break;
case 'permissions':
clearPermissions();
break;
case 'all':
default:
clearGameSearch();
clearPermissions();
break;
}
Prevention:
Use switch or if/else if chains for mutually exclusive options. Validate inputs with Zod enum schemas.
Fire-and-Forget Async Operations Without Error Handling
Symptom:
Unhandled promise rejection warnings in logs. Background operations fail silently.
Root Cause:
Calling async functions without await or .catch():
// WRONG: No error handling
this.downloadInBackground(id, zipPath); // Promise returned but discarded
// WRONG: IIFE without catch
(async () => {
await fs.writeFile(cachePath, data);
})();
CORRECT - Always handle the promise:
// Option 1: .catch() on the call
this.downloadInBackground(id, zipPath)
.catch(err => logger.error('Background download failed:', err));
// Option 2: .catch() on IIFE
(async () => {
await fs.writeFile(cachePath, data);
})().catch(err => logger.warn('Cache write failed:', err));
Prevention:
Always add .catch() to fire-and-forget promises. Linters can flag floating promises.
Exporting Internal Constants
Symptom:
Confusion about which exports are part of the public API. Accidental coupling between modules.
Root Cause:
Exporting constants that are only used within the module:
// WRONG: Unnecessary export
export const CUSTOM_MIME_TYPES = { ... }; // Only used in this file
export function getMimeType(filename: string): string {
// Uses CUSTOM_MIME_TYPES internally
}
CORRECT - Keep internals private:
// CORRECT: Don't export internal constants
const CUSTOM_MIME_TYPES = { ... }; // Private to this module
export function getMimeType(filename: string): string {
// Uses CUSTOM_MIME_TYPES internally
}
Why this matters:
- Reduces public API surface
- Makes it clear what's part of the module contract
- Prevents other code from depending on implementation details
- Makes refactoring easier (no external dependencies on internal constants)
Prevention:
- Only export what's needed by other modules
- Review exports before committing
- Use linters to detect unused exports
- Document public API explicitly
setInterval Blocking Graceful Shutdown
Symptom:
Process doesn't exit after SIGTERM. Node.js event loop stays alive.
Root Cause:
setInterval keeps the event loop alive by default:
// WRONG: Interval keeps process alive
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60000);
// Even after http.close(), this timer prevents process.exit()
CORRECT - Use .unref() on cleanup intervals:
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60000);
this.cleanupInterval.unref(); // Won't keep process alive
Prevention:
Call .unref() on all non-critical intervals (cleanup jobs, background tasks). Only keep critical intervals ref'd.
Hostname Includes Port Number
Symptom:
Hostname validation fails. External lookups don't match expected patterns.
Root Cause:
req.headers.host includes the port (e.g., localhost:3100):
// WRONG: Port included in hostname
const hostname = req.headers.host || 'localhost';
// hostname = 'localhost:3100' — validation regex rejects colons
CORRECT - Strip port before using hostname:
const hostname = (req.headers.host || 'localhost').split(':')[0];
// hostname = 'localhost' — clean hostname without port
Prevention:
Always strip port from req.headers.host before hostname validation or filesystem lookups.
Redundant Hostname Subdomain Variations
Symptom:
File system lookups try nonsensical hostnames like www.www.example.com or cdn.cdn.example.com.
Root Cause:
Generating subdomain variations without checking if hostname already has that prefix:
// WRONG: Always prepends subdomains
for (const sub of ['www', 'cdn', 'core']) {
variations.push(`${sub}.${hostname}`);
}
// If hostname is 'www.example.com', generates 'www.www.example.com'
CORRECT - Skip existing prefixes:
for (const sub of ['www', 'cdn', 'core']) {
if (!hostname.startsWith(`${sub}.`)) {
variations.push(`${sub}.${hostname}`);
}
}
Prevention:
Check for existing prefixes before adding subdomain variations.
Flash Games Must Have .swf Launch Commands to Appear in Browse
Symptom:
Flash games exist in the database but don't appear in search results or browse pages. Platform filters show zero Flash games.
Root Cause:
The backend filters Flash games whose launchCommand doesn't end in .swf. This
excludes Flash games with HTML wrapper pages or missing launch commands from
appearing in browse/search results.
CORRECT - Flash games with .swf launchCommands:
// These appear in browse/search results
launchCommand: "http://uploads.ungrounded.net/396000/396724_DAplayer_V6.swf"
launchCommand: "Games/Flash/game.swf"
WRONG - Flash games without .swf launchCommands:
// These are FILTERED OUT and won't appear in results
launchCommand: "Game.html" // HTML wrapper
launchCommand: "wrapper.php" // PHP wrapper
launchCommand: "https://example.com/play" // Web wrapper
Prevention:
- Check
launchCommandends with.swffor browseable Flash games - Use detail view (
GET /api/games/:id) for full game information - Note: Other platforms (HTML5, Windows) are not affected by this filter
Route Ordering Matters in Express
Symptom:
Static routes like /api/games/random return wrong results or 404 errors.
Root Cause:
Route registration order matters in Express. Parameterized routes like /:id
match anything and will shadow (hide) more specific static routes if registered
first.
WRONG - Parameterized route registered first:
// WRONG: This route matches EVERYTHING
app.get('/api/games/:id', (req, res) => {
// /api/games/random will match here with id='random'
// Then database query for game.id='random' returns nothing
});
// This static route is shadowed
app.get('/api/games/random', (req, res) => {
// This is NEVER reached!
});
CORRECT - Static route registered first:
// CORRECT: Specific route first
app.get('/api/games/random', (req, res) => {
// /api/games/random matches here
// This is reached!
});
// Parameterized route after
app.get('/api/games/:id', (req, res) => {
// Only matches if path doesn't match static routes
// /api/games/uuid-123 matches here
});
Prevention:
- Always register static/specific routes BEFORE parameterized routes
- Test special routes explicitly:
curl http://localhost:3100/api/games/random - Review route registration order when adding new endpoints
Use Nullish Coalescing (??) Instead of OR (||) for Config Defaults
Symptom:
Settings like homeRecentHours = 0 or maintenanceMode = false fall through
to defaults when they shouldn't.
Root Cause:
The || operator treats 0, "", and false as falsy and uses the default
even when explicitly set:
// WRONG: Using ||
const homeRecentHours = settingsValue || 24; // If settingsValue is 0:
// Result: 24 (wrong! 0 was explicitly set)
const maintenanceMode = settingsValue || false; // If settingsValue is false:
// Result: false (happens to work, but for wrong reason)
CORRECT - Using nullish coalescing (??):
// CORRECT: Using ??
const homeRecentHours = settingsValue ?? 24; // If settingsValue is 0:
// Result: 0 (correct! Only uses default if null or undefined)
const maintenanceMode = settingsValue ?? false; // If settingsValue is false:
// Result: false (correct! Only uses default if null or undefined)
Key difference:
||checks truthiness:0,"",false,null,undefinedall trigger default??checks nullishness: Onlynullandundefinedtrigger default
Prevention:
- Always use
??for config/settings defaults - Use
||only when you actually want to treat falsy values as missing
Array .sort() Mutates in Place
Symptom:
Building cache keys from arrays returns inconsistent results. Cache misses increase after sorting.
Root Cause:
JavaScript's .sort() mutates the original array:
// WRONG: Mutates the caller's array
function getCacheKey(tags: string[]): string {
const sorted = tags.sort(); // This MUTATES tags!
return sorted.join(':');
}
const myTags = ['z', 'a', 'b'];
const key1 = getCacheKey(myTags); // myTags is now ['a', 'b', 'z']
const key2 = getCacheKey(myTags); // myTags is still ['a', 'b', 'z']
// Cache might miss if code relies on original order
CORRECT - Spread operator creates copy:
// CORRECT: Creates copy before sorting
function getCacheKey(tags: string[]): string {
const sorted = [...tags].sort(); // Sorts the COPY
return sorted.join(':');
}
const myTags = ['z', 'a', 'b'];
const key1 = getCacheKey(myTags); // myTags is still ['z', 'a', 'b']
const key2 = getCacheKey(myTags); // myTags is still ['z', 'a', 'b']
// Consistent cache key regardless of mutations
Prevention:
- Always use
[...array].sort()orArray.from(array).sort() - If mutation is intentional, explicitly assign:
array = array.sort() - Use linters that warn about accidental mutations
Token Refresh Subscriber Queue Cleanup
Symptom:
Memory leaks in token refresh mechanism. Promise queues grow unbounded over time.
Root Cause:
When implementing a token refresh queue, subscribers waiting for the refresh might not be properly cleaned up on failure:
// WRONG: Queued promises never rejected on failure
private refreshPromise: Promise<TokenResponse> | null = null;
private refreshSubscribers: ((token: string) => void)[] = [];
async refreshToken(): Promise<TokenResponse> {
if (!this.refreshPromise) {
this.refreshPromise = this.performRefresh();
}
return new Promise((resolve, reject) => {
this.refreshSubscribers.push((token) => {
resolve({ accessToken: token });
});
});
// If refreshPromise rejects, subscribers are never notified
// They wait forever in memory!
}
CORRECT - Clean up on failure:
// CORRECT: Reject subscribers on refresh failure
private refreshPromise: Promise<TokenResponse> | null = null;
private refreshSubscribers: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
async refreshToken(): Promise<TokenResponse> {
if (!this.refreshPromise) {
this.refreshPromise = this.performRefresh().then(
(token) => {
// Success: resolve all subscribers
this.refreshSubscribers.forEach((sub) =>
sub.resolve(token.accessToken)
);
this.refreshSubscribers = [];
this.refreshPromise = null;
return token;
},
(error) => {
// IMPORTANT: Reject all subscribers on failure
this.refreshSubscribers.forEach((sub) =>
sub.reject(error)
);
this.refreshSubscribers = [];
this.refreshPromise = null;
throw error;
}
);
}
return new Promise((resolve, reject) => {
this.refreshSubscribers.push({
resolve,
reject, // Subscriber can be rejected now
});
});
}
Prevention:
- Always handle both success and failure in Promise chains
- Reject/cleanup pending subscribers on failure
- Monitor queue sizes in production for growth patterns
Frontend-Specific Pitfalls
Callback Props in useEffect Dependencies
Symptom:
Infinite re-render loops. Component re-renders constantly even when data hasn't changed.
Root Cause:
Passing callback props directly in useEffect dependency arrays. Parent re-renders create new function references, triggering the effect:
// WRONG: Callback in dependency array
interface Props {
onLoad?: () => void;
onError?: (error: Error) => void;
}
function Component({ onLoad, onError }: Props) {
useEffect(() => {
loadData()
.then(() => onLoad?.())
.catch((err) => onError?.(err));
}, [onLoad, onError]); // Parent re-renders create new functions → effect re-runs
}
CORRECT - Use useRef pattern:
// CORRECT: Store callbacks in refs
interface Props {
onLoad?: () => void;
onError?: (error: Error) => void;
}
function Component({ onLoad, onError }: Props) {
const onLoadRef = useRef(onLoad);
const onErrorRef = useRef(onError);
// Update refs when callbacks change
useEffect(() => {
onLoadRef.current = onLoad;
}, [onLoad]);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
// Main effect references .current (stable)
useEffect(() => {
loadData()
.then(() => onLoadRef.current?.())
.catch((err) => onErrorRef.current?.(err));
}, []); // Empty deps - only runs once
}
Why this pattern works:
- Ref
.currentis stable across re-renders - Main effect doesn't depend on callback props
- Separate effects update refs when callbacks change
- No infinite loops
Prevention:
- Never put callback props in
useEffectdependencies - Use
useRefto store callback references - Update refs in separate effects
- Reference
ref.currentin the main effect
Hardcoded Tailwind Colors vs Theme Tokens
Symptom:
UI doesn't adapt to dark/light mode changes. Colors look out of place or are hard to read in certain themes.
Root Cause:
Using hardcoded Tailwind color classes instead of theme-aware tokens:
// WRONG: Hardcoded colors that don't adapt to theme
<div className="text-gray-400 border-primary-500">
<p className="text-red-600">Error message</p>
</div>
CORRECT - Use theme tokens:
// CORRECT: Theme-aware tokens
<div className="text-muted-foreground border-primary">
<p className="text-destructive">Error message</p>
</div>
Common theme tokens:
| Instead of | Use |
|---|---|
text-gray-900, text-white |
text-foreground |
text-gray-400, text-gray-600 |
text-muted-foreground |
bg-white, bg-gray-900 |
bg-background |
bg-gray-100, bg-gray-800 |
bg-muted |
border-gray-200, border-gray-700 |
border-border |
text-blue-600, text-blue-400 |
text-primary |
bg-blue-600, bg-blue-500 |
bg-primary |
text-red-600, text-red-400 |
text-destructive |
bg-red-600, bg-red-500 |
bg-destructive |
Why this matters:
- Theme tokens automatically adapt to dark/light mode
- Consistent color usage across the app
- Easier to maintain and update themes
- Better accessibility (theme-aware contrast ratios)
Prevention:
- Use theme tokens from the design system
- Review components in both light and dark modes
- Document theme token conventions
- Use linters to detect hardcoded color usage
Image Preload Without Cleanup Causes Memory Leaks
Symptom:
Multiple images load simultaneously when src changes quickly. Stale callbacks fire with outdated URLs.
Root Cause:
Creating new Image() objects without cleanup:
// WRONG: No cleanup on src change
useEffect(() => {
const img = new Image();
img.onload = () => setImageSrc(src);
img.onerror = () => setError(true);
img.src = src;
}, [src]);
// When src changes, old Image keeps loading and may call stale handlers
CORRECT - Add cancellation and use refs for callbacks:
const onLoadRef = useRef(onLoad);
useEffect(() => { onLoadRef.current = onLoad; }, [onLoad]);
useEffect(() => {
let cancelled = false;
const img = new Image();
img.onload = () => { if (!cancelled) setImageSrc(src); };
img.onerror = () => { if (!cancelled) setError(true); };
img.src = src;
return () => { cancelled = true; img.onload = null; img.onerror = null; };
}, [src]);
Prevention:
Always return cleanup functions from useEffect. Store callbacks in refs to avoid dependency churn.
setTimeout Closure Leaks in useEffect
Symptom:
Old timeouts fire with stale state. Multiple competing timeouts when effect re-runs.
Root Cause:
setTimeout stored in a let variable inside useEffect leaks across re-runs:
// WRONG: Timeout in closure variable
useEffect(() => {
let timeout: NodeJS.Timeout;
timeout = setTimeout(() => setStatus('slow'), 10000);
return () => clearTimeout(timeout);
}, [status]); // Re-runs on status change, old timeout may already fire
CORRECT - Use useRef for timeouts:
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
timeoutRef.current = setTimeout(() => setStatus('slow'), 10000);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []); // Stable deps, ref survives re-renders
Prevention:
Store timeout/interval IDs in useRef, not let variables. Remove state values from dependency arrays when they only need to be read, not trigger re-runs.
Filter Chip IDs with Comma-Separated Values
Symptom:
Removing a filter chip removes the wrong filter. Multiple filters with commas get deleted instead of just the selected one.
Root Cause:
When building filter chips from comma-separated values, using array index as the chip identifier breaks when values contain commas:
// WRONG: Index-based chip IDs
const filterChips = values.split(',').map((value, index) => ({
id: index, // ❌ Breaks when values contain commas
label: value,
}));
// If tags = "action,puzzle,puzzle-adventure"
// chips[2] could match "puzzle-adventure" but index 2 is ambiguous
Solution:
Use encodeURIComponent as the chip identifier:
// CORRECT: Unique identifiers for each chip
const filterChips = values.split(',').map((value) => ({
id: encodeURIComponent(value), // ✅ Unique per value
label: value,
}));
// When removing chip with id "puzzle-adventure"
// Only that exact value is removed
const updatedValues = filterChips
.filter((chip) => chip.id !== chipIdToRemove)
.map((chip) => decodeURIComponent(chip.id))
.join(',');
Prevention:
- Always use content-based identifiers for dynamic collections
- Never rely on array indices for removable items
- Use
encodeURIComponentwhen building IDs from user content
Server-Sent Events (SSE) with Axios
Symptom:
SSE endpoints work in simple fetch examples but fail when using the axios API client.
Root Cause:
Axios doesn't support EventSource or streaming responses. It buffers the entire response, breaking the streaming contract of SSE.
// WRONG: Axios doesn't work with SSE
const response = await api.get('/api/stream/events'); // ❌ Won't stream
// Axios buffers entire response instead of opening EventSource
Solution:
Use native fetch() for SSE endpoints and manually attach authentication:
// CORRECT: Use fetch for SSE
export async function subscribeToEvents(
onMessage: (data: any) => void
): Promise<EventSource> {
const token = useAuthStore.getState().accessToken;
return new EventSource('/api/stream/events', {
headers: {
Authorization: `Bearer ${token}`, // ✅ Manually attach token
},
});
}
// Usage in component
useEffect(() => {
let eventSource: EventSource;
subscribeToEvents((data) => {
console.log('Event received:', data);
}).then((es) => {
eventSource = es;
eventSource.addEventListener('message', (e) => {
const event = JSON.parse(e.data);
onMessage(event);
});
});
return () => {
eventSource?.close();
};
}, []);
Prevention:
- Document SSE endpoints as exceptions to the "always use axios" rule
- Implement token refresh handling if tokens expire during long-lived SSE connections
- Include authentication token in EventSource constructor headers
Ruffle Window Type Safety
Symptom:
TypeScript errors when accessing Ruffle methods: Property 'RufflePlayer' does not exist on type 'Window'
Root Cause:
Ruffle is loaded via <script> tag and globally extends the Window object. Without type augmentation, TypeScript doesn't know about it.
// WRONG: No type augmentation
const ruffle = (window as any).RufflePlayer; // ❌ Uses any, loses type safety
Solution:
Add proper global type augmentation in a .d.ts file:
// src/types/ruffle.d.ts
declare global {
interface Window {
RufflePlayer: {
newest: () => {
create: (options: {
allowScriptAccess?: boolean;
[key: string]: any;
}) => RuffleInstance;
};
};
}
}
interface RuffleInstance {
play: () => void;
pause: () => void;
stop: () => void;
// ... other methods
}
export {};
Usage in components:
// CORRECT: Fully typed
const ruffle = window.RufflePlayer.newest().create({
allowScriptAccess: true,
});
ruffle.play(); // ✅ Type-safe, autocomplete works
Prevention:
- Always declare global types for third-party scripts loaded via
<script>tags - Never use
(window as any).something- it defeats type checking - Create
.d.tsfiles insrc/types/for external libraries
Data Integrity & Security Pitfalls
TOCTOU Race Conditions in Database Operations
Symptom:
Duplicate users created despite uniqueness checks. Data corruption under concurrent requests.
Root Cause:
Check-then-act pattern without transaction wrapping:
// WRONG: TOCTOU race condition
const existing = db.get('SELECT id FROM users WHERE username = ?', [username]);
if (existing) throw new Error('Username taken');
// Another request can insert between check and create!
db.run('INSERT INTO users ...', [username]);
CORRECT - Wrap in transaction:
// CORRECT: Atomic check + insert
const createUser = db.transaction(() => {
const existing = db.get('SELECT id FROM users WHERE username = ?', [username]);
if (existing) throw new Error('Username taken');
db.run('INSERT INTO users ...', [username]);
});
createUser();
Prevention:
- Always wrap uniqueness checks + inserts in
db.transaction() - Use database UNIQUE constraints as a safety net
- Test with concurrent requests (e.g.,
Promise.all([register(), register()]))
SSE Write-After-Close
Symptom:
Server crashes with "write after end" errors. SSE connections leak resources after client disconnects.
Root Cause:
Writing to a response after the client has disconnected:
// WRONG: No check for client disconnect
req.on('close', () => { /* cleanup */ });
// Later, after an async operation:
res.write(`data: ${JSON.stringify(update)}\n\n`); // Crashes if client gone!
CORRECT - Track closed state:
let closed = false;
req.on('close', () => { closed = true; /* cleanup */ });
// Check before every write
if (!closed) {
res.write(`data: ${JSON.stringify(update)}\n\n`);
}
Prevention:
- Add
closedflag set byreq.on('close')handler - Check flag before every
res.write()and after everyawait - Clean up resources (streams, intervals) in the close handler
ZIP Handle Leaks on Mount Failure
Symptom:
File descriptor exhaustion after repeated mount failures. "Too many open files" errors.
Root Cause:
ZIP opened but not closed when subsequent operations fail:
// WRONG: ZIP handle leaks if indexing fails
const zip = new StreamZip.async({ file: zipPath });
const entries = await zip.entries(); // If this throws...
// zip.close() never called!
CORRECT - Close in catch block:
const zip = new StreamZip.async({ file: zipPath });
try {
const entries = await zip.entries();
// ... build index, store in map
} catch (error) {
await zip.close(); // Clean up on failure
throw error;
}
Prevention:
- Always close opened resources in catch blocks
- Use try-finally for guaranteed cleanup
- Monitor open file descriptor count in production
Negative Cache Poisoning
Symptom:
Valid files return 404 permanently. Cache stores errors instead of just "not found" results.
Root Cause:
Caching all errors, not just ENOENT (file not found):
// WRONG: Caches permission errors, timeouts, etc.
try {
return await fs.readFile(path);
} catch (error) {
this.notFoundCache.set(path, true); // Bad: caches ALL errors
}
CORRECT - Only cache ENOENT:
try {
return await fs.readFile(path);
} catch (error: any) {
if (error.code === 'ENOENT') {
this.notFoundCache.set(path, true); // Only cache genuine "not found"
}
throw error; // Re-throw other errors for retry
}
Prevention:
- Only cache definitive "not found" results (ENOENT)
- Never cache transient errors (permission, timeout, network)
- Set TTL on negative cache entries
Double-Settlement of Promises
Symptom:
"Promise already resolved" errors. Unpredictable behavior from concurrent resolve/reject calls.
Root Cause:
Multiple code paths can settle the same Promise:
// WRONG: Both error and data handlers can settle
return new Promise((resolve, reject) => {
req.on('data', (chunk) => { /* ... */ });
req.on('end', () => resolve(body));
req.on('error', reject);
// If error fires AFTER end, double-settlement!
});
CORRECT - Use settled flag:
return new Promise((resolve, reject) => {
let settled = false;
const settle = () => { if (settled) return false; settled = true; return true; };
req.on('data', (chunk) => { /* ... */ });
req.on('end', () => { if (settle()) resolve(body); });
req.on('error', (err) => { if (settle()) reject(err); });
});
React StrictMode Double Session Start
Symptom:
Play sessions immediately ended after starting. Session tracking unreliable in development.
Root Cause:
React 18 StrictMode mounts, unmounts, then remounts components. The unmount triggers session cleanup:
// WRONG: StrictMode unmount ends session prematurely
useEffect(() => {
startSession();
return () => endSession(); // Called during StrictMode remount!
}, []);
CORRECT - Guard with mounted ref and delay:
const isMountedRef = useRef(false);
useEffect(() => {
const timeout = setTimeout(() => {
isMountedRef.current = true;
startSession();
}, 100); // Delay past StrictMode unmount
return () => {
clearTimeout(timeout);
if (isMountedRef.current) {
isMountedRef.current = false;
endSession();
}
};
}, []);
Prevention:
- Use
isMountedRef+ short timeout to survive StrictMode's unmount/remount - Only run cleanup if the component was actually fully mounted
- Test play tracking in both dev (StrictMode) and production builds
Quick Reference: Error Messages
| Error | Service | Quick Fix |
|---|---|---|
ENOENT: no such file |
Backend | Check FLASHPOINT_PATH in .env |
EADDRINUSE |
Any | Kill process or change port |
database is locked |
Backend | Close Flashpoint Launcher |
Token expired |
Backend | Implement token refresh |
CORS policy |
Frontend | Check DOMAIN in backend .env |
Ruffle is not defined |
Frontend | Run npm run copy-ruffle |
Module not found |
Any | Run npm install |
Type error |
Frontend/Backend | Run npm run typecheck |
Game files not loading |
Backend | Ensure backend game routes running |