A WebGL-based 2.5D graphics engine for isometric rendering
npm install bloody-engine



> A high-performance WebGL-based 2.5D graphics engine for isometric rendering on Node.js
Perfect for: Isometric games, city builders, multiplayer servers, procedural generation, and headless graphics processing.
Traditional game engines require separate client and server codebases, leading to duplicated logic and synchronization bugs. Bloody Engine solves this with an isomorphic architecture:
- Server-Side Rendering: Run the exact same rendering code on Node.js using headless WebGL
- Zero-Copy GPU Transfers: Structure of Arrays (SoA) architecture enables direct memory transfers
- Deterministic Simulation: Fixed timestep game loop ensures consistent state across all clients
- Authoritative Multiplayer: Server can render scenes virtually to validate visibility and prevent cheats
- Performance: Render 10,000+ entities at 60 FPS with instanced rendering
- Structure of Arrays (SoA) - Entity storage with typed arrays for zero-copy GPU transfers and cache-friendly access
- 2.5D Rendering - Optimized for isometric and dimetric projections with depth sorting
- Instanced Rendering - WebGL2 GPU instancing for rendering thousands of identical meshes in a single draw call
- Ring Buffer Streaming - Triple-buffered GPU streaming for zero-copy dynamic updates
- Server-Side Rendering - Headless WebGL rendering on Node.js using gl and @kmamal/sdl
- Batch Rendering - Efficient sprite batching with GPU-accelerated transformations
- Persistent Buffer Mapping - WebGL2 zero-copy GPU transfers for maximum performance
- Resource Management - Unified asset loading pipeline for textures and shaders
- Input System - Command queue pattern supporting SDL and network input sources
- Collision Detection - Spatial hashing with O(N) collision detection and configurable responses
- Networking - Client-side prediction, server reconciliation, and state synchronization
- Simulation - Pure game logic simulation system with entity management
- Game Loop - Fixed timestep ticker for deterministic game logic
- TypeScript - Fully typed for excellent developer experience
- Object Pooling - Memory-efficient object reuse patterns
- Window Management - SDL-based window creation for interactive applications
- Custom Properties - Opt-in extensible system for game-specific entity properties
- Node.js 18+ or 20+
- Native dependencies: Requires compilation of gl and @kmamal/sdl
Linux (Debian/Ubuntu):
``bash`
sudo apt-get install build-essential libx11-dev libgl1-mesa-dev libxi-dev
macOS:
`bash`
xcode-select --install
Windows:
- Install Visual Studio Build Tools
- Ensure "C++ build tools" workload is selected
`bash`
npm install bloody-engine
Let's create a simple isometric tile renderer from scratch:
`typescript
import { GraphicsDevice, HybridRenderer, Camera, SHADERS_V4 } from 'bloody-engine';
// 1. Create graphics device (800x600 window)
const device = new GraphicsDevice(800, 600);
const gl = device.getWebGL2Context();
// 2. Create isometric shader for instanced rendering
const shader = device.createShader(SHADERS_V4.vertex, SHADERS_V4.fragment);
// 3. Create hybrid renderer (auto-detects optimal rendering method)
const renderer = new HybridRenderer(gl, shader, shader, {
instancingThreshold: 100,
maxInstances: 10000,
tileSize: { width: 64, height: 32 },
zScale: 1.0
});
// 4. Create camera centered on screen
const camera = new Camera(400, 300, 1.0);
`
`typescript
// Set a gradient texture (or load your own with TextureAtlas)
renderer.setTexture(Texture.createGradient(gl, 256, 256));
// Create a 10x10 isometric grid
for (let x = 0; x < 10; x++) {
for (let y = 0; y < 10; y++) {
renderer.addSprite({
gridX: x, // Grid X position (tile index)
gridY: y, // Grid Y position (tile index)
z: 0, // Height/depth layer
width: 64, // Tile width
height: 32, // Tile height (isometric = width/2)
texIndex: 0,
color: { r: 1, g: 1, b: 1, a: 1 },
rotation: 0
});
}
}
`
`typescript
function render() {
// Clear screen with dark background
device.clear({ r: 0.1, g: 0.1, b: 0.15, a: 1.0 });
// Render all tiles with camera
const metrics = renderer.render(camera);
// Display to screen
device.present();
// Log performance
console.log(Drew ${metrics.instancedInstances} sprites in ${metrics.instancedDrawCalls} draw calls);
// Request next frame
requestAnimationFrame(render);
}
// Start rendering
render();
`
`typescript
// Add keyboard controls for camera
import { SDLWindow } from 'bloody-engine';
const window = new SDLWindow(800, 600, 'My Isometric Game');
window.onKeyDown = (key) => {
const speed = 10;
switch(key.toLowerCase()) {
case 'w': camera.y -= speed; break; // Up
case 's': camera.y += speed; break; // Down
case 'a': camera.x -= speed; break; // Left
case 'd': camera.x += speed; break; // Right
}
};
`
That's it! You now have a working isometric renderer with:
- ✅ 100 tiles rendered efficiently
- ✅ Camera controls
- ✅ Performance metrics
Next steps:
- Add sprites with TextureAtlas for custom graphicsSpatialHash
- Implement collision detection with ClientPredictor
- Add multiplayer with and ServerReconciler
- Quick Start
- Coordinate Systems
- API Overview
- Core Graphics
- Rendering
- Resource Loading
- Input System
- Simulation & Networking
- Utilities
- Examples
- Basic Rendering Setup
- Sprite Batch Rendering
- Instanced Rendering
- Shader System Guide
- Resource Loading
- Game Loop
- Entity System
- Input System
- Networking
- Advanced Examples
- Testing
- Platform Support
- Documentation
- Building
⚠️ IMPORTANT: Before building your game, understand the coordinate systems to avoid inverted controls!
Bloody Engine uses different coordinate systems for different purposes. Mixing these up is the #1 cause of inverted controls.
| System | Used For | Y-Axis | Example |
|--------|----------|--------|---------|
| Grid Space | Game logic, entity positions | Y-UP (↓ Y = North/Up) | entity.move(0, -1, 0) moves up on screen |camera.y += 10
| Screen Space | Rendering, camera, mouse | Y-DOWN (↓ Y = Down) | moves camera down |
Golden Rule: Use grid space for game logic, transform to screen space only for rendering.
❌ Wrong: camera.y += 1 for "up" movement (moves down on screen!)entity.move(0, -1, 0)
✅ Right: Use direction deltas: for North
| Key | Direction | Delta | Screen Effect |
|-----|-----------|-------|---------------|
| W / ↑ | North | {dx: 0, dy: -1} | ✅ Up |{dx: 0, dy: 1}
| S / ↓ | South | | ✅ Down |{dx: -1, dy: 0}
| A / ← | West | | ✅ Left |{dx: 1, dy: 0}
| D / → | East | | ✅ Right |
📖 Full Guide: docs/COORDINATE_SYSTEMS.md
🚀 Interactive Demo: Run npm run demo:coordinates after building
| Class | Description |
|-------|-------------|
| GraphicsDevice | Main graphics device with WebGL context management |
| Shader | Shader program compilation and uniform/attribute management |
| Texture | Texture creation, binding, and management |
| VertexBuffer / IndexBuffer | GPU buffer management for geometry |
| Camera | 2D camera with position, zoom, and view matrix |
| Class | Description |
|-------|-------------|
| BatchRenderer | Generic quad batch rendering |
| SpriteBatchRenderer | Sprite-specific batch renderer with depth sorting |
| InstancedRenderer | WebGL2 GPU instancing for thousands of instances in single draw call |
| HybridRenderer | Automatic detection between instanced and batch rendering |
| RingBuffer | Triple-buffered GPU ring buffer for zero-copy streaming |
| ProjectionConfig | Isometric/dimetric projection utilities |
| SpatialHash | Spatial partitioning for efficient queries |
| Class | Description |
|-------|-------------|
| NodeResourceLoader | File system resource loader for Node.js |
| NodeTextureLoader | PNG texture loading for Node.js |
| ResourcePipeline | Batch resource loading with caching |
| TextureAtlas | Sprite atlas packing and UV coordinate management |
| Class | Description |
|-------|-------------|
| CommandQueue | Thread-safe command queue for input |
| SDLInputSource | SDL keyboard/mouse input |
| NetworkInputSource | Network-based input for multiplayer |
| Class | Description |
|-------|-------------|
| EntityStorage | SoA storage with typed arrays for high-performance entity data |
| EntityHandle | Opaque handles for safe entity references |
| EntityTypeRegistry | Type string to ID mapping for storage efficiency |
| Entity / EntityManager | Entity component system (now uses SoA storage internally) |
| SimulationLoop | Deterministic game logic simulation |
| SoaWebGLRenderer | WebGL2 renderer with persistent buffer mapping |
| ClientPredictor | Client-side prediction for lag compensation |
| ServerReconciler | Server-side reconciliation |
| StateSnapshot | World state serialization |
| BinarySerializer | Efficient binary serialization |
| Class | Description |
|-------|-------------|
| ObjectPool | Generic object pooling for GC optimization |
| Matrix4Pool | Matrix4 specific pooling |
| lerp, lerpVec2, lerpVec3 | Interpolation utilities |
Build the library first, then run any demo:
`bash
npm run build # Build the library
$3
Beginner:
- Basic Rendering Setup - Create a device and render a quad
- Sprite Batch Rendering - Render multiple sprites with camera
- Game Loop - Implement a fixed timestep loop
Intermediate:
- Instanced Rendering - Render thousands of tiles efficiently
- Shader System Guide - Choose and use the right shader
- Entity System - Manage game entities with SoA storage
Advanced:
- Input System - Handle keyboard/mouse with command pattern
- Networking - Client-side prediction for multiplayer
- Networked Game Architecture - Full multiplayer setup
Code Examples
$3
`typescript
import { GraphicsDevice, Shader, Texture, VertexBuffer } from 'bloody-engine';// Create graphics device (800x600)
const device = new GraphicsDevice(800, 600);
const gl = device.getGLContext();
// Create a shader
const shader = device.createShader(
void main() {
gl_Position = uMatrix * vec4(aPosition, 1.0);
},
precision mediump float;
uniform vec3 uColor;
void main() {
gl_FragColor = vec4(uColor, 1.0);
});
// Create a gradient texture
const texture = Texture.createGradient(gl, 256, 256);
// Create geometry
const vertices = new Float32Array([
// x, y, z, u, v
-0.5, -0.5, 0, 0, 1,
0.5, -0.5, 0, 1, 1,
0.5, 0.5, 0, 1, 0,
-0.5, -0.5, 0, 0, 1,
0.5, 0.5, 0, 1, 0,
-0.5, 0.5, 0, 0, 0
]);
const buffer = new VertexBuffer(gl, vertices, 20); // 5 floats * 4 bytes
// Setup and render
device.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1.0 });
shader.use();
buffer.bind();
// ... configure attributes ...
gl.drawArrays(gl.TRIANGLES, 0, buffer.getVertexCount());
device.present();
`
`typescript
import { SpriteBatchRenderer, Camera, Texture, GraphicsDevice } from 'bloody-engine';
const device = new GraphicsDevice(800, 600);
const gl = device.getGLContext();
// Create shader (use built-in V2 shader for sprites)
const shader = device.createShader(vertexSource, fragmentSource);
// Create sprite batch renderer (capacity: 1000 sprites)
const batchRenderer = new SpriteBatchRenderer(gl, shader, 1000);
batchRenderer.setTexture(Texture.createGradient(gl, 256, 256));
// Create camera
const camera = new Camera(0, 0, 1.0); // x=0, y=0, zoom=1x
// Add sprites to batch
batchRenderer.addQuad({
x: 100, y: 100, z: 0,
width: 64, height: 64,
rotation: 0,
color: { r: 1, g: 1, b: 1, a: 1 },
texIndex: 0
});
// Render with camera
device.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1.0 });
batchRenderer.render(camera);
device.present();
`
For rendering thousands of identical meshes (floor tiles, sprites, particles), use the instanced renderer for massive performance gains:
`typescript
import {
HybridRenderer,
InstancedRenderer,
GraphicsDevice,
Camera
} from 'bloody-engine';
import { SHADERS_V4 } from 'bloody-engine/scene';
// Create graphics device
const device = new GraphicsDevice(800, 600);
// Check WebGL2 support
if (!device.isWebGL2()) {
throw new Error('Instanced rendering requires WebGL2');
}
if (!device.supportsInstancing()) {
throw new Error('GPU instancing not supported');
}
// Get WebGL2 context
const gl = device.getWebGL2Context();
// Create shaders
const instancedShader = device.createShader(
SHADERS_V4.vertex,
SHADERS_V4.fragment
);
// Use existing V3 shader for batch rendering
const batchShader = device.createShader(
SHADERS_V3.vertex,
SHADERS_V3.fragment
);
// Create hybrid renderer (auto-detects when to use instancing)
const renderer = new HybridRenderer(gl, instancedShader, batchShader, {
instancingThreshold: 100, // Use instancing for 100+ instances
maxInstances: 10000,
tileSize: { width: 64, height: 32 },
zScale: 1.0
});
// Create camera
const camera = new Camera(0, 0, 1.0);
// Set texture
renderer.setTexture(texture);
// Add thousands of floor tiles
for (let x = 0; x < 100; x++) {
for (let y = 0; y < 100; y++) {
renderer.addSprite({
gridX: x,
gridY: y,
z: 0,
width: 32,
height: 32,
texIndex: 0,
color: { r: 1, g: 1, b: 1, a: 1 },
rotation: 0
});
}
}
// Render (automatically uses instancing for large batches)
device.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1.0 });
const metrics = renderer.render(camera);
device.present();
// Check performance metrics
console.log(Instanced: ${metrics.instancedInstances} instances in ${metrics.instancedDrawCalls} draw calls);Batched: ${metrics.batchedInstances} instances in ${metrics.batchedDrawCalls} draw calls
console.log();
// Output with 10,000 tiles:
// Instanced: 10000 instances in 1 draw calls
// Batched: 0 instances in 0 draw calls
// (100x performance improvement!)
`
Performance Comparison:
| Instance Count | Batch Renderer | Instanced Renderer | Speedup |
|----------------|----------------|-------------------|---------|
| 100 | ~1ms | ~0.5ms | 2x |
| 1,000 | ~10ms | ~1ms | 10x |
| 10,000 | ~100ms | ~5ms | 20x |
When to Use Instanced Rendering:
- ✅ Floor tiles, walls, terrain (many identical meshes)
- ✅ Particles, projectiles (same geometry, different positions)
- ✅ Sprites with same texture and size
- ❌ Unique sprites with different textures
- ❌ Small batches (< 100 instances)
The HybridRenderer automatically detects when to use instancing, so you get the best of both worlds!
Tested on: AMD Ryzen 9 5900X, NVIDIA RTX 3080, Node.js v20
| Scenario | Entities | FPS | Draw Calls | Frame Time |
|----------|----------|-----|------------|------------|
| Batch Renderer | 1,000 | 60 | 1,000 | ~10ms |
| Instanced Renderer | 10,000 | 60 | 1 | ~5ms |
| Hybrid Renderer (mixed) | 5,000 | 60 | 50 | ~8ms |
| Collision Detection (Spatial Hash) | 10,000 | 60 | N/A | ~2ms |
| Full Game Loop (render + physics) | 5,000 | 60 | 50 | ~12ms |
Key Performance Insights:
- Instanced rendering provides 10-20x speedup for 1000+ identical entities
- SoA entity storage reduces memory usage by 60-70% vs traditional object-based storage
- Spatial hash collision detection maintains O(N) performance even at high entity counts
---
Bloody Engine provides 6 built-in shader versions optimized for different rendering scenarios. Choosing the right shader is critical for performance and correct rendering.
| Shader | Projection | Features | Best For | Coordinate System |
|--------|------------|----------|----------|-------------------|
| V1 | Flexible (any) | Basic texturing | Simple 2D quads | Screen/world (you control via matrix) |
| V2 | Flexible (any) | Color tint, texture atlas | 2D sprites with colors | Screen/world (you control via matrix) |
| V3 | Isometric | GPU-based transform | Isometric batch rendering | Grid coordinates |
| V4 | Isometric | Instanced rendering | Isometric tiles (1000+) | Grid coordinates |
| V5 | Top-Down | Instanced rendering | Top-down tiles (1000+) | World/pixel coordinates |
| V6 | Top-Down | GPU-based transform | Top-down batch rendering | World/pixel coordinates |
---
#### Isometric Projection (V3, V4)
`
Y
↑
| Screen coordinates are rotated 45°
| Creates a "fake 3D" look
└──────→ X
Grid (5,3) → Screen (64, 256)
`
- X axis: Diagonal (↗) on screen
- Y axis: Diagonal (↘) on screen
- Use for: Isometric games, city builders, RPGs
#### Top-Down Projection (V5, V6)
`
Y
↑
| Standard 2D coordinates
| Y-UP: lower Y = higher on screen
└──────→ X
World (320, 240) → Screen (320, 240)
`
- X axis: Horizontal (→) on screen
- Y axis: Vertical (↑) on screen (before camera transform)
- Use for: Top-down shooters, strategy games, platformers
---
Use these when you need full control over projection.
`typescript
import { SHADERS_V1, SHADERS_V2 } from 'bloody-engine';
// V1: Basic textured quads
const shaderV1 = device.createShader(
SHADERS_V1.vertex,
SHADERS_V1.fragment
);
// V2: Adds color tint and texture atlas support
const shaderV2 = device.createShader(
SHADERS_V2.vertex,
SHADERS_V2.fragment
);
`
Shader V1 attributes:
- aPosition (vec3): x, y, z positionaTexCoord
- (vec2): u, v texture coordinates
Shader V2 adds:
- aColor (vec4): r, g, b, a color tintaTexIndex
- (float): texture atlas index
Both use:
- uMatrix (mat4): You control projection (orthographic, perspective, etc.)
When to use:
- ✅ Standard 2D games with custom projections
- ✅ UI rendering
- ✅ Non-isometric views
- ✅ When you need camera matrix flexibility
---
Use these for isometric games (city builders, isometric RPGs).
`typescript
import { SHADERS_V3, SHADERS_V4 } from 'bloody-engine';
// V3: Batch rendering (CPU-side batching)
const shaderV3 = device.createShader(
SHADERS_V3.vertex,
SHADERS_V3.fragment
);
// V4: Instanced rendering (GPU-side batching)
const shaderV4 = device.createShader(
SHADERS_V4.vertex,
SHADERS_V4.fragment
);
`
Isometric projection in shader:
`glsl`
// Both V3 and V4 use this transform:
vec2 isoScreen = vec2(
(aGridPosition.x - aGridPosition.y) uTileSize.x 0.5,
(aGridPosition.x + aGridPosition.y) uTileSize.y 0.5
);
Coordinate system:
- Input: Grid coordinates (tile indices, not pixels)
- Example: gridX: 5, gridY: 3 → 5th tile right, 3rd tile down
V3 (Batch) uses:
- uCamera (vec3): x, y position and zoomuResolution
- (vec2): screen width/height for NDC conversionuTileSize
- (vec2): isometric tile dimensionsuZScale
- (float): height exaggeration
V4 (Instanced) uses:
- uMatrix (mat4): camera transform (more flexible)uTileSize
- (vec2): isometric tile dimensionsuZScale
- (float): height exaggeration
When to use:
- ✅ Isometric city builders
- ✅ Isometric RPGs
- ✅ Games with diagonal movement
- ❌ Not suitable for standard 2D views
Example usage (V3):
`typescript
const batchRenderer = new GPUBasedSpriteBatchRenderer(
gl, shaderV3, 10000,
{ width: 64, height: 32 }, // Isometric tile size
1.0 // Z scale
);
// Add sprites in GRID coordinates (tile indices)
batchRenderer.addQuad({
x: 320, y: 240, // World pixel position (optional)
gridX: 5, // Grid X tile index ← Actual rendering position
gridY: 3, // Grid Y tile index ← Actual rendering position
z: 0,
width: 64,
height: 32,
color: { r: 1, g: 1, b: 1, a: 1 }
});
`
---
Use these for standard 2D top-down games.
`typescript
import { SHADERS_V5, SHADERS_V6 } from 'bloody-engine';
// V5: Top-down instanced rendering
const shaderV5 = device.createShader(
SHADERS_V5.vertex,
SHADERS_V5.fragment
);
// V6: Top-down batch rendering
const shaderV6 = device.createShader(
SHADERS_V6.vertex,
SHADERS_V6.fragment
);
`
Direct coordinates (no isometric transform):
`glsl`
// Both V5 and V6 use direct coordinates:
vec2 worldPos = aGridPosition + localPos; // No transform!
Coordinate system:
- Input: World/pixel coordinates (direct screen positions)
- Example: gridX: 320, gridY: 240 → position at (320, 240) pixels
V6 (Batch) uses:
- uCamera (vec3): x, y position and zoomuResolution
- (vec2): screen width/height for NDC conversionuZScale
- (float): depth scale (for sorting, not visual height)
V5 (Instanced) uses:
- uMatrix (mat4): camera transformuZScale
- (float): depth scale
When to use:
- ✅ Top-down shooters
- ✅ Strategy games
- ✅ Platformers
- ✅ Any standard 2D game
- ❌ Not suitable for isometric views
Example usage (V6):
`typescript
const batchRenderer = new GPUBasedSpriteBatchRenderer(
gl, shaderV6, 10000,
{ width: 64, height: 64 }, // Regular tile size (square)
1.0 // Z scale for depth sorting
);
// Add sprites in WORLD/PIXEL coordinates (direct positions)
batchRenderer.addQuad({
x: 320, // Direct pixel X position
y: 240, // Direct pixel Y position
gridX: 320, // Same as x (used for rendering)
gridY: 240, // Same as y (used for rendering)
z: 0, // Depth for sorting (0 = background)
width: 64,
height: 64,
color: { r: 1, g: 0.5, b: 0.2, a: 1 }
});
`
---
If you're using V3/V4 (isometric) and want to switch to V5/V6 (top-down):
`typescript
// BEFORE (Isometric V3/V4):
renderer.addQuad({
x: 320, y: 240,
gridX: Math.floor(x / 64), // Converting to grid indices
gridY: Math.floor(y / 64),
z: y / 64,
width: 64,
height: 32, // Non-square for isometric
...
});
// AFTER (Top-Down V5/V6):
renderer.addQuad({
x: 320, y: 240,
gridX: x, // Direct pixel coordinates
gridY: y, // Direct pixel coordinates
z: 0, // Depth for sorting only
width: 64,
height: 64, // Square for top-down
...
});
`
Key changes:
1. Remove Math.floor() - use coordinates as-isz
2. Use square tiles (width === height) instead of isometric (height = width/2)
3. is now only for depth sorting, not visual height
---
Decision tree:
``
Need isometric view?
├─ Yes → Use V3 (batch) or V4 (instanced)
│ └─ Coordinate system: Grid indices (0, 1, 2, ...)
│
└─ No (standard 2D) → Need custom projection?
├─ Yes → Use V1 or V2 (flexible)
│ └─ You control uMatrix for any projection
│
└─ No (standard top-down) → Use V5 (instanced) or V6 (batch)
└─ Coordinate system: Pixel/world coordinates (320, 240, ...)
Performance recommendations:
| Entity Count | Recommended Shader | Rationale |
|--------------|-------------------|-----------|
| < 100 | Any | Overhead is negligible |
| 100-1000 | V3, V6 (batch) | Good balance |
| 1000+ | V4, V5 (instanced) | Best GPU utilization |
| Mixed | HybridRenderer | Auto-detects optimal method |
---
`typescript
import {
GraphicsDevice,
Camera,
GPUBasedSpriteBatchRenderer,
SHADERS_V6
} from 'bloody-engine';
// Setup
const device = new GraphicsDevice(800, 600);
const gl = device.getGLContext();
// Use top-down batch shader (V6)
const shader = device.createShader(
SHADERS_V6.vertex,
SHADERS_V6.fragment
);
// Create batch renderer with top-down settings
const renderer = new GPUBasedSpriteBatchRenderer(
gl,
shader,
10000, // Max sprites
{ width: 64, height: 64 }, // Square tiles
1.0 // Z scale (depth sorting)
);
// Create camera
const camera = new Camera(400, 300, 1.0); // Center of screen, 1x zoom
// Game loop
function render() {
// Clear screen
device.clear({ r: 0.1, g: 0.1, b: 0.15, a: 1.0 });
// Add player at pixel position (300, 200)
renderer.addQuad({
x: 300,
y: 200,
gridX: 300, // Direct pixel position
gridY: 200,
z: 1, // Player in front of background
width: 64,
height: 64,
color: { r: 0.2, g: 0.6, b: 1.0, a: 1 },
rotation: 0,
texIndex: 0
});
// Add enemy at pixel position (500, 350)
renderer.addQuad({
x: 500,
y: 350,
gridX: 500, // Direct pixel position
gridY: 350,
z: 1, // Same layer as player
width: 48,
height: 48,
color: { r: 1.0, g: 0.2, b: 0.2, a: 1 },
rotation: 0,
texIndex: 0
});
// Render with camera
renderer.render(camera);
device.present();
}
`
Notice: No coordinate conversion needed! Just pass pixel positions directly.
---
`typescript
import {
ResourceLoaderFactory,
createResourcePipeline,
NodeTextureLoader
} from 'bloody-engine';
// Create resource pipeline
const pipeline = await createResourcePipeline({
concurrency: 5,
cache: true,
baseDir: process.cwd()
});
// Load shaders
const shaders = await pipeline.loadShaders([
{ name: 'basic', vertex: 'shaders/basic.vert', fragment: 'shaders/basic.frag' }
]);
// Batch load resources
const { succeeded, failed } = await pipeline.loadMultiple([
'textures/sprite1.png',
'textures/sprite2.png'
]);
// Load texture from PNG
const textureLoader = new NodeTextureLoader();
const texture = await textureLoader.loadTexture(gl, 'textures/sprite.png');
`
`typescript
import { Ticker, type TickerConfig } from 'bloody-engine';
const config: TickerConfig = {
targetFPS: 60,
fixedDeltaTime: 1 / 60, // 60 physics updates per second
maxFrameTime: 0.25 // Prevent spiral of death
};
const ticker = new Ticker(config);
ticker.start({
update: (deltaTime) => {
// Game logic update (fixed timestep)
console.log(Update: ${deltaTime.toFixed(3)}s);Render: interpolation=${interpolation.toFixed(3)}
},
render: (interpolation) => {
// Render with interpolation factor
console.log();
}
});
// Get performance metrics
const metrics = ticker.getMetrics();
console.log(FPS: ${metrics.fps}, Delta Time: ${metrics.deltaTime}s);`
The engine now uses Structure of Arrays (SoA) for entity storage, providing:
- Zero-copy GPU transfers - Direct typed array uploads to WebGL buffers
- Better cache locality - Sequential memory access patterns
- SIMD-ready - Data layout enables future vectorization
- Extensible properties - Add custom typed arrays for game-specific data
`typescript
import { EntityManager, type EntityState } from 'bloody-engine';
// Create entity manager (uses SoA storage internally)
const manager = new EntityManager();
// Create entity with initial state
const player = manager.createEntity("player", {
gridPos: { xgrid: 10, ygrid: 20, zheight: 5 },
velocity: { x: 1, y: 0, z: 0 },
speed: 2.5
});
// All existing methods work unchanged (full backward compatibility)
player.setGridPos(50, 60, 10);
player.move(5, 5, 0);
player.setVelocity(2, 1, 0);
// Query entities
const players = manager.getEntitiesByType("player");
const nearby = manager.getEntitiesInRange(50, 60, 100);
// Register custom properties (opt-in extension)
manager.registerCustomProperty("health", Float32Array);
manager.registerCustomProperty("stamina", Uint32Array);
// Access SoA storage directly for advanced use
const storage = manager.getStorage();
const handle = (player as any).getHandle();
// Set custom property
storage.setCustomProperty(handle.index, "health", 100);
`
`typescript
import {
CommandQueue,
SDLInputSource,
createSDLInputSource,
CommandType
} from 'bloody-engine';
// Create command queue
const queue = new CommandQueue();
// Create SDL input source (requires SDL window)
const sdlWindow = new SDLWindow(800, 600, 'Game');
const inputSource = createSDLInputSource(sdlWindow, {
keyMapping: {
moveUp: ['w', 'arrowup'],
moveDown: ['s', 'arrowdown'],
moveLeft: ['a', 'arrowleft'],
moveRight: ['d', 'arrowright']
}
});
// Process input in game loop
while (running) {
// Collect input commands
inputSource.update(queue);
// Process commands
while (queue.hasCommands()) {
const command = queue.dequeue();
switch (command.type) {
case CommandType.Move:
handleMove(command);
break;
case CommandType.Attack:
handleAttack(command);
break;
}
}
}
`
`typescript
import {
createClientPredictor,
ClientPredictor,
type ClientInputMessage
} from 'bloody-engine';
// Create predictor with config
const predictor = createClientPredictor({
maxPredictedTicks: 100,
reconciliationDelay: 100 // ms
});
// Client loop: send input
const onInput = (input: MoveCommand) => {
const tick = currentTick;
predictor.addLocalInput(tick, input);
// Send to server
socket.send(JSON.stringify({
type: 'client_input',
tick,
input
} as ClientInputMessage));
};
// Receive server update
const onServerUpdate = (message: ServerStateUpdateMessage) => {
const result = predictor.reconcile(message);
if (result.corrected) {
console.log(Reconciled: corrected=${result.corrected}, error=${result.error});`
}
};
`typescript
import { ObjectPool, type ObjectPoolConfig } from 'bloody-engine';
// Create pool for Vector3 objects
const pool = new ObjectPool
initialSize: 100,
growthFactor: 2,
factory: () => ({ x: 0, y: 0, z: 0 }),
reset: (obj) => { obj.x = 0; obj.y = 0; obj.z = 0; }
});
// Acquire from pool
const vec = pool.acquire();
vec.x = 10; vec.y = 20; vec.z = 30;
// Return to pool when done
pool.release(vec);
// Get pool statistics
const stats = pool.getStats();
console.log(Size: ${stats.size}, Active: ${stats.active}, Hits: ${stats.hits});`
`typescript
import { ProjectionConfig, gridToScreen, screenToGrid } from 'bloody-engine';
// Configure isometric projection
const config = new ProjectionConfig({
tileWidth: 64,
tileHeight: 32,
angle: Math.PI / 6, // 30 degrees
screenWidth: 800,
screenHeight: 600
});
// Convert grid to screen coordinates
const gridPos = { xgrid: 5, ygrid: 3, zheight: 0 };
const screenPos = gridToScreen(gridPos, config);
console.log(Screen: x=${screenPos.xscreen}, y=${screenPos.yscreen});
// Convert screen to grid coordinates
const gridPos2 = screenToGrid(screenPos, config);
`
`typescript
import { TextureAtlas, AtlasLoader } from 'bloody-engine';
// Load sprite atlas
const atlas = await AtlasLoader.loadFromJSON(gl, 'atlas.json');
// Get sprite info
const sprite = atlas.getSprite('player_idle_01');
// Use UV rect for rendering
batchRenderer.addQuad({
x: 100, y: 100, z: 0,
width: sprite.pixelRect.width,
height: sprite.pixelRect.height,
uvRect: sprite.uvRect
});
`
The Structure of Arrays (SoA) architecture enables direct GPU transfers without intermediate copying:
`typescript
import { EntityStorage, SoaWebGLRenderer, Shader } from 'bloody-engine';
// Create SoA storage and populate with entities
const storage = new EntityStorage(10000);
// ... add entities ...
// Create WebGL2 renderer with persistent buffer mapping
const gl = device.getGLContext() as WebGL2RenderingContext;
const shader = device.createShader(vertexSource, fragmentSource);
const renderer = new SoaWebGLRenderer(gl, shader, 10000);
// Initialize persistent buffers (maps GPU memory to CPU arrays)
renderer.initialize(storage);
// In your render loop:
function render() {
// Zero-copy: Update GPU memory directly
renderer.render(storage);
// GPU sees changes immediately (coherent mapping)
device.present();
}
`
Performance Benefits:
- No bufferSubData overhead: Direct CPU→GPU memory writes
- Better cache locality: Sequential access to entity data
- SIMD-ready: Data layout enables future vectorization
- Memory efficient: Typed arrays use 2-4x less memory than objects
Add game-specific properties without modifying core classes:
`typescript
import { EntityManager, Float32Array, Uint32Array } from 'bloody-engine';
const manager = new EntityManager();
// Register custom properties (opt-in)
manager.registerCustomProperty('health', Float32Array); // Float values
manager.registerCustomProperty('mana', Uint32Array); // Integer values
manager.registerCustomProperty('xp', Float32Array); // Experience points
// Create entity
const player = manager.createEntity('player');
// Access storage to set custom properties
const storage = manager.getStorage();
const handle = (player as any).getHandle();
storage.setCustomProperty(handle.index, 'health', 100.0);
storage.setCustomProperty(handle.index, 'mana', 50);
storage.setCustomProperty(handle.index, 'xp', 0);
// Bulk update all entities (cache-efficient)
const allHealth = storage.getCustomPropertyArray('health');
for (let i = 0; i < storage.getCount(); i++) {
allHealth[i] += 10; // Regenerate health for all entities
}
`
Understanding the SoA memory layout helps with performance optimization:
`typescript
// Entity 0 data at indices 0-2
positions[0] = entity0.x
positions[1] = entity0.y
positions[2] = entity0.z
// Entity 1 data at indices 3-5
positions[3] = entity1.x
positions[4] = entity1.y
positions[5] = entity1.z
// Same pattern for all properties:
// - velocities: [vx0, vy0, vz0, vx1, vy1, vz1, ...]
// - colors: [r0, g0, b0, a0, r1, g1, b1, a1, ...]
// - rotations: [rot0, rot1, rot2, ...]
// - textureIds: [id0, id1, id2, ...]
`
This layout enables:
- Zero-copy views: positions.subarray(0, entityCount * 3) → GPU
- Bulk updates: Loop through contiguous memory
- Cache efficiency: Predictable access patterns
If you have existing code using the old Array-of-Structures pattern:
Before (AoS - deprecated):
`typescript`
// This no longer works
const entity = new Entity("player1", "player", {
gridPos: { xgrid: 10, ygrid: 20, zheight: 0 }
});
After (SoA - current):
`typescript
// Use EntityManager factory
const manager = new EntityManager();
const entity = manager.createEntity("player", {
gridPos: { xgrid: 10, ygrid: 20, zheight: 0 }
});
// Everything else works the same!
entity.setGridPos(50, 60, 10);
entity.move(5, 5, 0);
entity.setVelocity(1, 0, 0);
`
Breaking Changes:
- Direct new Entity() construction is no longer supportedEntityManager.createEntity()
- Use for all entity creationEntityManager.deserializeAll()
- Deserialization: Use instead of Entity.deserialize()
`typescript
import {
SimulationLoop,
Entity,
ClientPredictor,
ServerReconciler,
StateSnapshot,
Ticker
} from 'bloody-engine';
// Server-side simulation
const serverSim = new SimulationLoop({
fixedDeltaTime: 1 / 60
});
// Client-side prediction
const clientPredictor = createClientPredictor({
maxPredictedTicks: 100
});
// Server reconciliation
const serverReconciler = createServerReconciler({
maxRewindTicks: 50
});
// Game loop on client
const ticker = new Ticker({ targetFPS: 60 });
ticker.start({
update: (deltaTime) => {
// 1. Collect input and send to server
const input = collectInput();
socket.send({ type: 'input', input, tick: currentTick });
// 2. Predict locally
clientPredictor.addLocalInput(currentTick, input);
const predictedState = predictState();
// 3. Handle server updates
onServerUpdate = (update) => {
clientPredictor.reconcile(update);
};
},
render: (interpolation) => {
renderGame(clientPredictor.getLatestState(), interpolation);
}
});
`
`typescript
import { SimulationLoop, Entity } from 'bloody-engine';
// Create two simulations for testing determinism
const sim1 = new SimulationLoop({ fixedDeltaTime: 1 / 60, seed: 12345 });
const sim2 = new SimulationLoop({ fixedDeltaTime: 1 / 60, seed: 12345 });
// Add identical entities
sim1.addEntity(new Entity({ id: '1', x: 0, y: 0 }));
sim2.addEntity(new Entity({ id: '1', x: 0, y: 0 }));
// Run simulations
for (let i = 0; i < 1000; i++) {
sim1.update(1 / 60);
sim2.update(1 / 60);
}
// Verify determinism
const state1 = sim1.getStateSnapshot();
const state2 = sim2.getStateSnapshot();
console.log('Deterministic:', JSON.stringify(state1) === JSON.stringify(state2));
`
The engine includes comprehensive tests for determinism, visual regression, and SoA functionality:
`bashRun all tests
npm test
Dependencies
- gl - Headless WebGL for Node.js
- @kmamal/sdl - SDL2 bindings for window and input management
- pngjs - PNG image decoding
Platform Support
| Platform | Status | Notes |
|----------|--------|-------|
| Node.js (Linux) | ✅ Full | Headless rendering + SDL window |
| Node.js (macOS) | ✅ Full | Headless rendering + SDL window |
| Node.js (Windows) | ✅ Full | Headless rendering + SDL window |
| Browser | ⚠️ Planned | WebGL rendering planned |
Documentation
$3
`
src/
├── core/ # Core graphics and utilities
│ ├── graphics-device.ts
│ ├── shader.ts
│ ├── texture.ts
│ ├── buffer.ts
│ ├── object-pool.ts
│ └── ticker.ts
├── rendering/ # Rendering systems
│ ├── batch-renderer.ts
│ ├── camera.ts
│ ├── projection.ts
│ ├── instanced-renderer.ts # WebGL2 GPU instancing
│ ├── hybrid-renderer.ts # Auto-detection (instanced vs batch)
│ ├── ring-buffer.ts # Triple-buffered GPU streaming
│ ├── soa-webgl-renderer.ts # WebGL2 zero-copy renderer
│ └── spatial-hash.ts
├── input/ # Input system (command queue)
│ ├── command-queue.ts
│ ├── sdl-input-source.ts
│ └── network-input-source.ts
├── simulation/ # Game logic simulation
│ ├── entity.ts
│ ├── entity-manager.ts
│ ├── entity-storage.ts # SoA storage with typed arrays
│ ├── entity-handle.ts # Handle-based entity references
│ ├── entity-type-registry.ts # Type string to ID mapping
│ └── simulation-loop.ts
├── networking/ # Networking for multiplayer
│ ├── client-predictor.ts
│ ├── server-reconciler.ts
│ ├── state-snapshot.ts
│ └── binary-serializer.ts
└── platforms/
└── node/ # Node.js-specific implementations
├── node-context.ts
├── node-resource-loader.ts
└── sdl-window.ts
`$3
- Separation of Concerns: Rendering, input, simulation, and networking are completely separate systems
- Structure of Arrays (SoA): Entity storage uses typed arrays for zero-copy GPU transfers and cache efficiency
- Deterministic Simulation: Game logic runs in fixed timestep for consistency across clients
- Command Pattern: All input goes through a command queue for easy recording/replay
- Client-Side Prediction: Reduces perceived lag in networked games
- Object Pooling: Minimizes garbage collection for smooth performance
For detailed documentation and architecture, see docs/README.MD.
Troubleshooting
$3
Error:
gyp ERR! stack Error: not found: makeSolution: Install build tools for your platform:
- Linux:
sudo apt-get install build-essential
- macOS: xcode-select --install
- Windows: Install Visual Studio Build Tools$3
Error:
Error: SDL could not create windowSolution:
- On Linux headless servers, use a virtual display:
`bash
Xvfb :99 -screen 0 1024x768x24 &
export DISPLAY=:99
`
- On Windows, ensure GPU drivers are up to date
- On macOS, ensure you're not in a restricted sandbox environment$3
Error:
Error: Failed to load textureSolution:
- Ensure PNG files are in the correct directory relative to
process.cwd()
- Verify @kmamal/sdl is installed correctly
- Check file permissions$3
Symptoms: Low FPS, frame drops
Solutions:
1. Use
HybridRenderer for automatic optimization
2. Reduce entity count or use spatial partitioning
3. Enable instanced rendering for 1000+ identical sprites
4. Check for memory leaks in the entity system
5. Profile with Chrome DevTools (Node.js inspector)For more help, please open an issue.
Contributing
Contributions are welcome! Please follow these guidelines:
$3
`bash
Clone the repository
git clone https://github.com/BLooDek/bloody-engine.git
cd bloody-engineInstall dependencies
npm installBuild the library
npm run buildRun tests in watch mode
npm run test:watchRun linting
npm run lint
`$3
1. Create a feature branch:
git checkout -b feature/my-feature
2. Make your changes and add tests
3. Ensure tests pass: npm test
4. Ensure code is linted: npm run lint
5. Commit with clear messages
6. Push and create a pull request$3
- TypeScript: Use strict mode, provide types for all exports
- Tests: Add unit tests for new features
- Documentation: Update README and code comments as needed
- Formatting: Follow existing code style (enforced by ESLint)
$3
When reporting bugs, please include:
- Node.js version
- Platform (Linux/macOS/Windows)
- Minimal reproducible example
- Expected vs actual behavior
Building
`bash
npm run build
`This will generate the distribution files in
dist/node/`.MIT License - see LICENSE for details.
https://github.com/BLooDek/bloody-engine
Report bugs and request features at: https://github.com/BLooDek/bloody-engine/issues