A minimal horror game framework for building browser-based games with atmospheric tension and fear elements.
Skeleton Crew is a flexible, configuration-driven game framework designed specifically for horror games. It provides core game systems and horror-specific mechanics that can be extended to create different types of games - from roguelike dungeon crawlers to narrative puzzle games.
Key Features:
- 🎮 Complete game loop with entity and scene management
- 👁️ Limited visibility system for atmospheric tension
- 🔊 Audio system for ambient sounds and effects
- 😱 Horror mechanics: sanity, threats, resource scarcity
- ⚙️ Configuration-driven design (no code changes needed)
- 🧪 Comprehensive test suite with property-based testing
- 🎨 Two complete example games included
v2.0 Features:
- ✨ Particle system for atmospheric effects
- 🎬 Animation system with tweening and effects
- ⚡ Spatial partitioning for performance optimization
v3.0 Features (NEW!):
- 🌟 Dynamic lighting with shadows
- 🧭 A* pathfinding for AI navigation
- 💬 Branching dialogue system
- 🎯 Quest and objective tracking
- 🤖 Behavior tree AI system
This repository contains:
- Core Framework (
framework/) - Reusable game systems - Dungeon Crawler (
games/dungeon-crawler/) - Roguelike with procedural generation - Puzzle Game (
games/puzzle-game/) - Narrative exploration with fixed puzzles
Both games use 100% of the same framework code, demonstrating the framework's flexibility.
# Clone the repository
git clone https://github.com/MiChaelinzo/Chambers.git
cd Chambers
# Install dependencies
npm installDungeon Crawler:
# Open in browser
open games/dungeon-crawler/index.htmlPuzzle Game:
# Open in browser
open games/puzzle-game/index.htmlNew Features Demo:
# Open in browser
open examples/new-features-demo.html# Run all tests (unit + property-based)
npm test
# Run tests in watch mode
npm run test:watchThe framework is organized into modular systems:
framework/
├── core/ # Game loop, entities, scenes
│ ├── GameLoop.js # RAF-based update/render cycle
│ ├── Entity.js # Base class for all game objects
│ ├── Scene.js # Scene container and lifecycle
│ ├── SceneManager.js # Scene loading and transitions
│ └── Game.js # Main game orchestrator
├── systems/ # Input, rendering, audio
│ ├── InputHandler.js # Keyboard/mouse input
│ ├── Renderer.js # Canvas 2D rendering
│ └── AudioSystem.js # Web Audio API wrapper
├── mechanics/ # Horror-specific systems
│ ├── VisibilitySystem.js # Limited sight radius
│ ├── ResourceManager.js # Consumable resources
│ ├── ThreatSystem.js # Danger detection
│ └── SanitySystem.js # Mental state tracking
└── utils/
└── ConfigLoader.js # JSON configuration loading
Entity System: Everything in the game world is an Entity with position, state, and behavior.
Scene Management: Games are organized into Scenes that can be loaded, unloaded, and transitioned between.
Configuration-Driven: Game behavior is defined through JSON files, not code changes.
Horror Mechanics: Built-in systems for visibility, resources, threats, and sanity create atmospheric tension.
A roguelike with procedural generation, combat, and permadeath.
Features:
- Procedurally generated dungeons
- Enemy AI with chase behavior
- Combat system with health and damage
- Collectible items
- Permadeath mechanics
Controls:
- WASD/Arrow Keys: Move
- Mouse: Attack enemies
- Space: Collect items
Try it: Open games/dungeon-crawler/index.html
A narrative exploration game with fixed puzzles and checkpoints.
Features:
- Hand-crafted puzzle rooms
- Interactive objects (doors, keys, clues)
- Inventory system
- Story progression
- Save/load system
Controls:
- WASD/Arrow Keys: Move
- E: Interact with objects
- I: Open inventory
- ESC: Save game
Try it: Open games/puzzle-game/index.html
mkdir -p games/my-game/config
mkdir -p games/my-game/entitiesgame.json - Main game configuration:
{
"game": {
"title": "My Horror Game",
"startScene": "first_scene",
"visibility": {
"mode": "circular",
"radius": 150
},
"resources": [
{ "name": "health", "max": 100, "start": 100 }
],
"mechanics": {
"permadeath": false,
"checkpoints": true,
"combat": false
}
}
}entities.json - Define entity types:
{
"entityTypes": {
"player": {
"sprite": "player.png",
"width": 32,
"height": 32,
"speed": 100,
"health": 100
},
"enemy": {
"sprite": "enemy.png",
"width": 32,
"height": 32,
"speed": 50,
"damage": 10
}
}
}scenes.json - Define game scenes:
{
"scenes": {
"first_scene": {
"type": "fixed",
"width": 800,
"height": 600,
"entities": [
{ "type": "player", "x": 100, "y": 100 },
{ "type": "enemy", "x": 400, "y": 300 }
]
}
}
}Extend the base Entity class for custom behavior:
// games/my-game/entities/CustomEnemy.js
import { Entity } from '../../../framework/core/Entity.js';
export class CustomEnemy extends Entity {
constructor(id, x, y, config) {
super(id, 'custom_enemy', x, y, config);
this.health = config.health || 50;
}
update(deltaTime, context) {
// Custom AI logic
const player = context.scene.getEntityByType('player');
if (player) {
// Move towards player
const dx = player.position.x - this.position.x;
const dy = player.position.y - this.position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
this.position.x += (dx / distance) * this.config.speed * deltaTime;
this.position.y += (dy / distance) * this.config.speed * deltaTime;
}
}
}
render(ctx, camera) {
ctx.fillStyle = 'red';
ctx.fillRect(
this.position.x - camera.x,
this.position.y - camera.y,
this.config.width,
this.config.height
);
}
}// games/my-game/main.js
import { Game } from '../../framework/core/Game.js';
import { ConfigLoader } from '../../framework/utils/ConfigLoader.js';
import { CustomEnemy } from './entities/CustomEnemy.js';
async function init() {
// Load configurations
const gameConfig = await ConfigLoader.loadConfig('./config/game.json');
const entityConfig = await ConfigLoader.loadConfig('./config/entities.json');
const sceneConfig = await ConfigLoader.loadConfig('./config/scenes.json');
// Merge configurations
const config = ConfigLoader.mergeConfigs(gameConfig, {
entities: entityConfig.entityTypes,
scenes: sceneConfig.scenes
});
// Register custom entities
const entityRegistry = {
'custom_enemy': CustomEnemy
};
// Initialize game
const canvas = document.getElementById('gameCanvas');
const game = new Game(canvas, config, entityRegistry);
game.start();
}
init();<!DOCTYPE html>
<html>
<head>
<title>My Horror Game</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
canvas {
border: 2px solid #333;
}
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<script type="module" src="main.js"></script>
</body>
</html>| Property | Type | Description |
|---|---|---|
title |
string | Game title |
startScene |
string | Initial scene ID |
visibility.mode |
string | "circular", "cone", or "none" |
visibility.radius |
number | Visibility range in pixels |
resources |
array | Tracked resources (health, stamina, etc.) |
mechanics.permadeath |
boolean | Restart on death |
mechanics.checkpoints |
boolean | Enable save points |
mechanics.combat |
boolean | Enable combat system |
| Property | Type | Description |
|---|---|---|
sprite |
string | Image file path |
width |
number | Entity width in pixels |
height |
number | Entity height in pixels |
speed |
number | Movement speed |
health |
number | Hit points |
damage |
number | Attack damage |
collides |
boolean | Enable collision detection |
| Property | Type | Description |
|---|---|---|
type |
string | "fixed" or "procedural" |
width |
number | Scene width in pixels |
height |
number | Scene height in pixels |
generator |
string | Generator type (for procedural) |
entities |
array | Entity spawn definitions |
The framework includes comprehensive test coverage:
tests/
├── unit/ # Unit tests for specific functions
├── property/ # Property-based tests (fast-check)
└── integration/ # End-to-end game flow tests
The framework uses property-based testing to verify correctness across many random inputs:
// Example: Testing visibility system
test('entities within radius are visible', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1000 }),
fc.integer({ min: 0, max: 1000 }),
fc.integer({ min: 50, max: 200 }),
(playerX, playerY, radius) => {
const visibility = new VisibilitySystem({ radius });
const player = { position: { x: playerX, y: playerY } };
// Entity exactly at radius should be visible
const entity = {
position: {
x: playerX + radius,
y: playerY
}
};
expect(visibility.isVisible(player, entity)).toBe(true);
}
),
{ numRuns: 100 }
);
});# Run only unit tests
npm test -- tests/unit
# Run only property tests
npm test -- tests/property
# Run tests for specific system
npm test -- tests/unit/core/GameLoop.test.jsconst loop = new GameLoop(updateFn, renderFn, targetFPS);
loop.start(); // Start the game loop
loop.stop(); // Stop the game loop
loop.pause(); // Pause without stopping
loop.resume(); // Resume from pauseclass MyEntity extends Entity {
update(deltaTime, context) {
// Update logic (called every frame)
}
render(ctx, camera) {
// Rendering logic
}
onInteract(player, context) {
// Interaction logic
}
}sceneManager.registerScene('scene_id', scene);
sceneManager.loadScene('scene_id', transitionData);
const current = sceneManager.getCurrentScene();const visibility = new VisibilitySystem({ radius: 150, mode: 'circular' });
const visibleEntities = visibility.getVisibleEntities(player, allEntities, scene);
const isVisible = visibility.isVisible(player, entity, scene);resources.addResource('health', 100, 100, 0);
resources.consume('health', 10);
resources.restore('health', 20);
const health = resources.getResource('health');audio.loadSound('effect_id', 'path/to/sound.mp3');
audio.playSound('effect_id', volume, loop);
audio.playAmbient('ambient_id', volume);
audio.stopAmbient();- Update Design: Document the feature in
.kiro/specs/skeleton-crew-framework/design.md - Write Tests: Create property-based and unit tests
- Implement: Add the feature to the framework
- Verify: Run tests to ensure correctness
- Document: Update this README with usage examples
Enable debug mode in your game configuration:
{
"game": {
"debug": true
}
}This will:
- Show entity bounding boxes
- Display FPS counter
- Log system events to console
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
- Additional horror mechanics (fog of war, sound propagation)
- More example games
- Performance optimizations
- Additional generators (maze, cave, building)
- Mobile touch controls
- Multiplayer support
This project is licensed under the terms specified in the LICENSE file.
- Inspired by classic horror games and roguelikes
- Built with modern web technologies
- Tested with Jest and fast-check
- Issues: Report bugs via GitHub Issues
- Discussions: Ask questions in GitHub Discussions
- Documentation: See
.kiro/specs/for detailed specifications
Built with 👻 by the Skeleton Crew team
Create atmospheric effects like blood splatters, dust clouds, fog, sparks, and smoke.
// Get particle system from game
const particles = game.getParticleSystem();
// Emit blood splatter
particles.emit({
position: { x: player.position.x, y: player.position.y },
type: 'blood',
count: 50,
speed: 100
});
// Create continuous fog emitter
particles.createEmitter('fog_emitter', {
x: 400,
y: 300,
rate: 20, // particles per second
lifetime: 5, // emitter duration (-1 for infinite)
particle: {
type: 'fog',
lifetime: 3.0
}
});
// Custom particle configuration
particles.emit({
position: { x: 100, y: 100 },
type: 'default',
count: 30,
color: '#ff00ff',
size: 8,
speed: 150,
lifetime: 2.0,
accelerationY: 100 // gravity
});Particle Types:
blood- Red particles with gravitydust- Brown particles, slow movementfog- Large, soft particles that risespark- Fast, bright particlessmoke- Gray particles that risedefault- Customizable particles
Smooth animations, tweening, and visual effects.
// Get animation system from game
const anim = game.getAnimationSystem();
// Fade in an entity
anim.fadeIn(entity, 1.0, () => {
console.log('Fade complete!');
});
// Fade out an entity
anim.fadeOut(entity, 1.0);
// Shake effect (for damage, explosions)
anim.shake(entity, intensity = 10, duration = 0.3);
// Tween a single property
anim.tween({
target: entity.position,
property: 'x',
to: 500,
duration: 2.0,
easing: 'easeInOut',
onComplete: () => console.log('Done!')
});
// Tween multiple properties at once
anim.tweenMultiple(
entity.position,
{ x: 500, y: 300 },
1.5,
'easeInOut'
);
// Register frame-based animation
anim.registerAnimation('walk', {
frames: [0, 1, 2, 3],
frameRate: 10,
loop: true
});
// Play animation on entity
anim.playAnimation(entity, 'walk');Easing Functions:
linear- Constant speedeaseIn- Slow start, fast endeaseOut- Fast start, slow endeaseInOut- Slow start and endeaseInCubic,easeOutCubic,easeInOutCubic- Cubic curveselastic- Elastic bounce effectbounce- Bouncing effect
Efficient spatial partitioning for collision detection and proximity queries.
// Get spatial grid from game
const grid = game.getSpatialGrid();
// Grid is automatically updated each frame
// Use it for efficient queries:
// Find entities near a position
const nearbyEntities = grid.getNearby(x, y, radius);
// Find entities in a rectangular area
const entitiesInRect = grid.getInRect(x, y, width, height);
// Get potential collision pairs (entities in same cells)
const collisionPairs = grid.getPotentialCollisions();
// Get grid statistics
const stats = grid.getStats();
console.log(`Occupied cells: ${stats.occupiedCells}/${stats.totalCells}`);
console.log(`Entities per cell: ${stats.averageEntitiesPerCell}`);
// Debug render (shows grid and entity distribution)
grid.debugRender(ctx, camera);Performance Benefits:
- O(1) spatial queries instead of O(n²)
- Efficient collision detection for many entities
- Reduces CPU usage with large entity counts
- Configurable cell size for different game scales
Here's how to use all new features together:
// In your game entity update method
update(deltaTime, context) {
const game = context.game;
// Use spatial grid for efficient enemy detection
const nearbyEnemies = game.getSpatialGrid()
.getNearby(this.position.x, this.position.y, 200)
.filter(e => e.type === 'enemy');
if (nearbyEnemies.length > 0) {
// Spawn warning particles
game.getParticleSystem().emit({
position: this.position,
type: 'spark',
count: 10
});
// Shake camera
game.getAnimationSystem().shake(this, 5, 0.2);
}
}
// When player takes damage
takeDamage(amount) {
this.health -= amount;
// Blood splatter effect
game.getParticleSystem().emit({
position: this.position,
type: 'blood',
count: 30
});
// Flash red animation
const anim = game.getAnimationSystem();
this.state.tint = '#ff0000';
anim.tween({
target: this.state,
property: 'tintAlpha',
from: 1,
to: 0,
duration: 0.3,
onComplete: () => {
this.state.tint = null;
}
});
}Configure new systems in your game config:
{
"game": {
"particles": {
"maxParticles": 2000
},
"spatialGridCellSize": 100,
"worldWidth": 2000,
"worldHeight": 2000
}
}See examples/new-features-demo.html for an interactive demonstration of all new features.
Comprehensive save/load system for game state persistence using localStorage.
import { SaveSystem } from './framework/utils/SaveSystem.js';
// Initialize save system
const saveSystem = new SaveSystem({
gameId: 'my_horror_game',
maxSlots: 3,
autoSave: true,
autoSaveInterval: 60000 // 1 minute
});
// Save game state
const gameState = {
player: { x: 100, y: 200, health: 80 },
level: 5,
inventory: ['key', 'flashlight']
};
const metadata = {
playerName: 'Player1',
playtime: 3600,
levelName: 'Dark Corridor'
};
saveSystem.save(0, gameState, metadata);
// Load game state
const savedData = saveSystem.load(0);
if (savedData) {
console.log('Loaded game:', savedData.gameState);
console.log('Metadata:', savedData.metadata);
}
// Check if save exists
if (saveSystem.hasSave(0)) {
console.log('Save slot 0 has data');
}
// Get all save metadata
const allSaves = saveSystem.getAllSaveMetadata();
allSaves.forEach((save, slot) => {
if (save) {
console.log(`Slot ${slot}: ${save.metadata.playerName} - ${save.metadata.levelName}`);
}
});
// Export/Import for backup
const exportedData = saveSystem.exportSave(0);
saveSystem.importSave(1, exportedData); // Copy to slot 1
// Auto-save
saveSystem.startAutoSave(() => {
return getCurrentGameState(); // Your function to get current state
}, 0);
// Storage statistics
const stats = saveSystem.getStorageStats();
console.log(`Using ${stats.totalSize} bytes across ${stats.slotsUsed} slots`);Track player accomplishments with support for progress tracking, secret achievements, and rewards.
import { AchievementSystem } from './framework/systems/AchievementSystem.js';
// Initialize achievement system
const achievements = new AchievementSystem({
gameId: 'my_horror_game',
enableNotifications: true,
notificationDuration: 3000
});
// Register achievements
achievements.registerAchievement({
id: 'first_kill',
name: 'First Blood',
description: 'Defeat your first enemy',
type: 'simple'
});
achievements.registerAchievement({
id: 'kill_100',
name: 'Centurion',
description: 'Defeat 100 enemies',
type: 'progress',
target: 100,
reward: { gold: 500, item: 'legendary_sword' }
});
achievements.registerAchievement({
id: 'secret_room',
name: '???',
description: 'Find the hidden chamber',
type: 'secret',
hidden: true
});
// Unlock simple achievement
achievements.unlock('first_kill');
// Update progress achievement
achievements.incrementProgress('kill_100', 1); // Increment by 1
achievements.updateProgress('kill_100', 50); // Set to 50
// Check achievement status
if (achievements.isUnlocked('first_kill')) {
console.log('Achievement unlocked!');
}
// Get progress
const progress = achievements.getProgress('kill_100');
console.log(`Progress: ${progress.current}/${progress.target} (${progress.percentage}%)`);
// Get all achievements
const allAchievements = achievements.getAllAchievements();
allAchievements.forEach(achievement => {
console.log(`${achievement.name}: ${achievement.unlocked ? 'Unlocked' : 'Locked'}`);
});
// Get statistics
const stats = achievements.getStats();
console.log(`Unlocked ${stats.unlocked}/${stats.total} achievements (${stats.percentage}%)`);
// Register callbacks
achievements.onUnlock((achievement) => {
console.log(`Achievement unlocked: ${achievement.name}`);
if (achievement.reward) {
giveRewardToPlayer(achievement.reward);
}
});
achievements.onProgress((achievement, current, target) => {
console.log(`${achievement.name}: ${current}/${target}`);
});Achievement Types:
simple- One-time unlock achievementsprogress- Track incremental progress towards a goalsecret- Hidden until unlockedchallenge- Difficult achievements with special requirements
New audio features for better sound control and spatial audio.
// Preload multiple sounds
await audio.preloadSounds([
{ id: 'bgm1', url: 'music/theme.mp3' },
{ id: 'footstep', url: 'sfx/footstep.wav' },
{ id: 'scream', url: 'sfx/scream.wav' }
]);
// Fade in ambient music
audio.fadeInAmbient('bgm1', 0.7, 2.0); // Fade to 0.7 volume over 2 seconds
// Fade out ambient music
audio.fadeOutAmbient(3.0); // Fade out over 3 seconds
// Fade to new volume
audio.fadeAmbientTo(0.3, 1.0); // Fade to 0.3 volume over 1 second
// Spatial audio (distance-based volume)
const audioObj = audio.playSpatialSound(
'footstep',
{ x: 500, y: 300 }, // Sound source position
{ x: 100, y: 100 }, // Listener position
1.0, // Max volume
false // Don't loop
);
// Update spatial audio as positions change
audio.updateSpatialSound(
audioObj,
{ x: 450, y: 300 }, // New source position
{ x: 150, y: 100 }, // New listener position
1.0
);
// Ambient volume control
audio.setAmbientVolume(0.5);
const currentVolume = audio.getAmbientVolume();Add new systems to your game configuration:
{
"game": {
"particles": {
"maxParticles": 2000
},
"spatialGridCellSize": 100,
"worldWidth": 2000,
"worldHeight": 2000,
"audio": {
"enableSpatialAudio": true,
"maxDistance": 1000
}
}
}