@hiddentao/clockwork-engine
Version:
A TypeScript/PIXI.js game engine for deterministic, replayable games with built-in rendering
667 lines (521 loc) • 17.8 kB
Markdown
# 🎮 Game Objects
The Game Object system is the foundation for all entities in your Clockwork Engine games. It provides a robust, type-safe architecture for creating, managing, and organizing game entities with automatic lifecycle management, event handling, and deterministic behavior.
## Overview
Game Objects in Clockwork Engine are structured around two main concepts:
- **GameObject**: Abstract base class for all game entities
- **GameObjectGroup**: Collection manager for organizing objects by type
This system automatically handles object registration, type grouping, lifecycle management, and provides a consistent interface for all game entities.
## How It Works
### Automatic Type Registration
When you create a GameObject, it automatically registers with the engine and is grouped by its type:
```typescript
class Player extends GameObject {
getType(): string {
return "Player" // Objects are grouped by this string
}
}
// Automatically creates/joins "Player" group
const player = new Player("player-1", new Vector2D(100, 100), new Vector2D(32, 32))
```
### Lifecycle Management
The engine handles the complete object lifecycle:
1. **Creation**: Object registers with engine and joins appropriate group
2. **Updates**: Object receives frame-based updates via group processing
3. **Events**: Object emits events for state changes
4. **Destruction**: Destroyed objects are filtered from processing but retained for cleanup
5. **Cleanup**: Destroyed objects are periodically removed from groups
## Key Concepts
### Abstract GameObject Base Class
All game entities extend the abstract GameObject class, providing consistent interface and behavior:
```typescript
abstract class GameObject<T extends GameObjectEvents = GameObjectEvents>
extends EventEmitter<T>
implements IPositionable, ICollisionSource
```
### Type-Based Grouping
Objects are automatically organized into groups based on their `getType()` return value:
```typescript
// These objects automatically join separate groups
class Player extends GameObject { getType() { return "Player" } }
class Enemy extends GameObject { getType() { return "Enemy" } }
class Bullet extends GameObject { getType() { return "Bullet" } }
```
### Event System Integration
GameObjects inherit from EventEmitter and automatically emit standard events:
```typescript
// Standard events all GameObjects emit
interface GameObjectEvents {
positionChanged: (gameObject: GameObject, oldPosition: Vector2D, newPosition: Vector2D) => void
healthChanged: (gameObject: GameObject, health: number, maxHealth: number) => void
destroyed: (gameObject: GameObject) => void
}
```
## GameObject API
### Constructor
```typescript
constructor(
id: string, // Unique identifier
position: Vector2D, // Initial position
size: Vector2D, // Object dimensions
health = 0, // Initial health
engine?: GameEngineInterface // Optional auto-registration
)
```
### Abstract Methods (Must Implement)
```typescript
abstract getType(): string // Return type identifier for grouping
```
### Core Properties and Methods
#### Position and Movement
```typescript
// Position
getPosition(): Vector2D
setPosition(position: Vector2D): void // Emits positionChanged event
// Size
getSize(): Vector2D
setSize(size: Vector2D): void
// Velocity (used by default update method)
getVelocity(): Vector2D
setVelocity(velocity: Vector2D): void
// Rotation
getRotation(): number
setRotation(rotation: number): void
```
#### Health System
```typescript
getHealth(): number
getMaxHealth(): number
setHealth(health: number): void // Emits healthChanged event
takeDamage(amount: number): void // Emits healthChanged event
heal(amount: number): void // Emits healthChanged event
```
#### Lifecycle
```typescript
getId(): string // Get unique identifier
isDestroyed(): boolean // Check if destroyed
destroy(): void // Mark for destruction, emits destroyed event
update(deltaFrames: number): void // Called each frame by GameObjectGroup
```
#### Engine Integration
```typescript
getEngine(): GameEngineInterface | undefined
registerWithEngine(engine: GameEngineInterface): void
getCollisionSourceId(): string // For collision detection integration
```
#### Serialization
```typescript
serialize(): SerializedGameObject // Serialize current state
static deserialize(data: any): GameObject // Reconstruct from serialized data
```
## GameObjectGroup API
### Collection Management
```typescript
add(gameObject: T): T // Add object to group
remove(gameObject: T): boolean // Remove specific object
has(gameObject: T): boolean // Check if object exists
hasId(id: string): boolean // Check if ID exists
getById(id: string): T | undefined // Get object by ID
clear(): void // Remove all objects
```
### Query and Statistics
```typescript
getAll(): T[] // Get all active (non-destroyed) objects
size(): number // Total count (including destroyed)
activeSize(): number // Count of active objects only
clearDestroyed(): number // Remove destroyed objects, return count removed
```
### Game Loop Integration
```typescript
update(deltaFrames: number, totalFrames: number): void // Update all objects
```
## Code Examples
### Creating a Simple Game Object
```typescript
class Bullet extends GameObject {
private damage: number
private speed: number
constructor(id: string, position: Vector2D, direction: Vector2D, damage: number) {
super(id, position, new Vector2D(4, 4), 1) // Small size, 1 health
this.damage = damage
this.speed = 300
this.setVelocity(direction.normalize().multiply(this.speed))
}
getType(): string {
return "Bullet"
}
getDamage(): number {
return this.damage
}
update(deltaFrames: number): void {
super.update(deltaFrames) // Handles movement
// Destroy if off-screen (example bounds check)
const pos = this.getPosition()
if (pos.x < 0 || pos.x > 800 || pos.y < 0 || pos.y > 600) {
this.destroy()
}
}
serialize() {
return {
...super.serialize(),
damage: this.damage,
speed: this.speed
}
}
static deserialize(data: any): Bullet {
const bullet = new Bullet(
data.id,
new Vector2D(data.position.x, data.position.y),
new Vector2D(data.velocity.x, data.velocity.y).normalize(),
data.damage
)
bullet.setHealth(data.health)
return bullet
}
}
```
### Complex Game Object with Custom Events
```typescript
interface PlayerEvents extends GameObjectEvents {
levelUp: (player: Player, newLevel: number) => void
experienceGained: (player: Player, amount: number, total: number) => void
}
class Player extends GameObject<PlayerEvents> {
private level: number = 1
private experience: number = 0
private experienceToNext: number = 100
constructor(id: string, position: Vector2D) {
super(id, position, new Vector2D(32, 32), 100)
}
getType(): string {
return "Player"
}
gainExperience(amount: number): void {
this.experience += amount
this.emit("experienceGained", this, amount, this.experience)
while (this.experience >= this.experienceToNext) {
this.levelUp()
}
}
private levelUp(): void {
this.experience -= this.experienceToNext
this.level++
this.experienceToNext = Math.floor(this.experienceToNext * 1.2)
// Increase max health on level up
this.maxHealth += 10
this.setHealth(this.maxHealth)
this.emit("levelUp", this, this.level)
}
getLevel(): number {
return this.level
}
serialize() {
return {
...super.serialize(),
level: this.level,
experience: this.experience,
experienceToNext: this.experienceToNext
}
}
}
```
### Working with GameObjectGroups
```typescript
class MyGame extends GameEngine {
setup(): void {
// Create objects - they auto-register and group themselves
const player = new Player("player-1", new Vector2D(100, 100))
const enemy1 = new Enemy("enemy-1", new Vector2D(200, 200))
const enemy2 = new Enemy("enemy-2", new Vector2D(300, 150))
}
update(deltaFrames: number): void {
super.update(deltaFrames)
// Get typed groups
const players = this.getGameObjectGroup<Player>("Player")
const enemies = this.getGameObjectGroup<Enemy>("Enemy")
const bullets = this.getGameObjectGroup<Bullet>("Bullet")
if (players && enemies && bullets) {
this.checkCollisions(players, enemies, bullets)
}
}
private checkCollisions(
players: GameObjectGroup<Player>,
enemies: GameObjectGroup<Enemy>,
bullets: GameObjectGroup<Bullet>
): void {
// Type-safe access to grouped objects
for (const bullet of bullets.getAllActive()) {
for (const enemy of enemies.getAllActive()) {
if (this.checkCollision(bullet, enemy)) {
enemy.takeDamage(bullet.getDamage())
bullet.destroy()
}
}
}
}
}
```
### Event Handling
```typescript
class GameUI {
constructor(private engine: GameEngine) {
this.setupEventListeners()
}
private setupEventListeners(): void {
const playerGroup = this.engine.getGameObjectGroup<Player>("Player")
const player = playerGroup?.getById("player-1")
if (player) {
// Listen to standard events
player.on("healthChanged", (player, health, maxHealth) => {
this.updateHealthBar(health / maxHealth)
})
player.on("positionChanged", (player, oldPos, newPos) => {
this.updateMinimap(player.getId(), newPos)
})
// Listen to custom events
player.on("levelUp", (player, newLevel) => {
this.showLevelUpEffect(newLevel)
})
player.on("experienceGained", (player, amount, total) => {
this.updateExperienceBar(total)
})
}
}
}
```
### Serialization and Deserialization
```typescript
class GameSaveManager {
saveGame(engine: GameEngine): string {
const gameData = {
players: [],
enemies: [],
bullets: []
}
// Serialize each group
const playerGroup = engine.getGameObjectGroup<Player>("Player")
if (playerGroup) {
gameData.players = playerGroup.getAllActive().map(p => p.serialize())
}
const enemyGroup = engine.getGameObjectGroup<Enemy>("Enemy")
if (enemyGroup) {
gameData.enemies = enemyGroup.getAllActive().map(e => e.serialize())
}
return JSON.stringify(gameData)
}
loadGame(engine: GameEngine, saveData: string): void {
const gameData = JSON.parse(saveData)
// Deserialize and recreate objects
for (const playerData of gameData.players) {
const player = Player.deserialize(playerData)
engine.registerGameObject(player)
}
for (const enemyData of gameData.enemies) {
const enemy = Enemy.deserialize(enemyData)
engine.registerGameObject(enemy)
}
}
}
```
## Edge Cases and Gotchas
### Object Registration Timing
**Issue**: Objects created before engine initialization may not register properly.
**Solution**: Always initialize engine before creating objects, or register manually:
```typescript
// GOOD - Engine ready
engine.reset("seed")
const player = new Player("player-1", position) // Auto-registers
// ALTERNATIVE - Manual registration
const player = new Player("player-1", position)
player.registerWithEngine(engine)
```
### Type String Consistency
**Issue**: Inconsistent type strings prevent proper grouping.
**Solution**: Use constants or enums for type strings:
```typescript
const GAME_OBJECT_TYPES = {
PLAYER: "Player",
ENEMY: "Enemy",
BULLET: "Bullet"
} as const
class Player extends GameObject {
getType(): string {
return GAME_OBJECT_TYPES.PLAYER // Consistent reference
}
}
```
### Destroyed Object Cleanup
**Issue**: Destroyed objects remain in groups until cleanup, consuming memory.
**Solution**: Periodically clean up destroyed objects:
```typescript
// In your game loop or at level transitions
for (const [type, group] of this.gameObjectGroups.entries()) {
const removedCount = group.clearDestroyed()
console.log(`Cleaned up ${removedCount} destroyed ${type} objects`)
}
```
### Circular References in Events
**Issue**: Event handlers can create circular references preventing garbage collection.
**Solution**: Always remove event listeners when objects are destroyed:
```typescript
class Player extends GameObject {
destroy(): void {
this.removeAllListeners() // Clear all event listeners
super.destroy()
}
}
```
### Serialization of Complex Objects
**Issue**: Complex nested objects may not serialize properly.
**Solution**: Implement custom serialization for complex types:
```typescript
class ComplexGameObject extends GameObject {
private customData: ComplexType
serialize() {
return {
...super.serialize(),
customData: this.customData.serialize() // Custom serialization
}
}
static deserialize(data: any): ComplexGameObject {
const obj = new ComplexGameObject(data.id, ...)
obj.customData = ComplexType.deserialize(data.customData)
return obj
}
}
```
## Performance Considerations
### Group Size and Updates
Large groups with many objects can impact performance during updates.
**Optimization**: Consider spatial partitioning for objects that don't need every-frame updates:
```typescript
class BackgroundObject extends GameObject {
private updateCounter: number = 0
update(deltaFrames: number): void {
// Update less frequently for background objects
this.updateCounter += deltaFrames
if (this.updateCounter >= 5) { // Every 5 frames
super.update(this.updateCounter)
this.updateCounter = 0
}
}
}
```
### Memory Usage with Large Object Counts
Thousands of objects can consume significant memory.
**Optimization**: Use object pooling for frequently created/destroyed objects:
```typescript
class BulletPool {
private pool: Bullet[] = []
private activeCount: number = 0
getBullet(): Bullet {
if (this.pool.length > this.activeCount) {
return this.pool[this.activeCount++]
}
const bullet = new Bullet(`bullet-${this.pool.length}`, new Vector2D(), new Vector2D(), 10)
this.pool.push(bullet)
this.activeCount++
return bullet
}
returnBullet(bullet: Bullet): void {
// Move to end of active section
const index = this.pool.indexOf(bullet)
if (index !== -1 && index < this.activeCount) {
this.pool[index] = this.pool[this.activeCount - 1]
this.pool[this.activeCount - 1] = bullet
this.activeCount--
}
}
}
```
### Event Handler Performance
Many event handlers can slow down object operations.
**Optimization**: Batch events or use more specific event filtering:
```typescript
// Instead of listening to every position change
player.on("positionChanged", handler) // Called frequently
// Use custom events for significant changes only
player.emitSignificantMovement(oldZone, newZone) // Called rarely
```
## Best Practices
### 1. Use Descriptive Type Names
```typescript
// GOOD - Clear, consistent type names
class PlayerCharacter extends GameObject {
getType() { return "PlayerCharacter" }
}
class EnemyTank extends GameObject {
getType() { return "EnemyTank" }
}
// BAD - Generic or inconsistent names
class Player extends GameObject {
getType() { return "player" } // Inconsistent casing
}
```
### 2. Implement Proper Serialization
```typescript
// GOOD - Complete serialization
class Enemy extends GameObject {
serialize() {
return {
...super.serialize(),
aiState: this.aiState,
target: this.target?.getId(), // Serialize references as IDs
lastAction: this.lastAction
}
}
static deserialize(data: any, engine: GameEngine): Enemy {
const enemy = new Enemy(data.id, ...)
enemy.aiState = data.aiState
// Resolve target reference
enemy.target = engine.getGameObjectGroup("Player")?.getById(data.target)
return enemy
}
}
```
### 3. Use Type-Safe Event Handling
```typescript
// GOOD - Custom event interfaces
interface WeaponEvents extends GameObjectEvents {
fired: (weapon: Weapon, direction: Vector2D) => void
reloaded: (weapon: Weapon, ammo: number) => void
}
class Weapon extends GameObject<WeaponEvents> {
fire(direction: Vector2D): void {
if (this.canFire()) {
this.createBullet(direction)
this.emit("fired", this, direction)
}
}
}
```
### 4. Handle Object Dependencies
```typescript
// GOOD - Graceful handling of missing dependencies
class AI extends GameObject {
update(deltaFrames: number): void {
const playerGroup = this.getEngine()?.getGameObjectGroup<Player>("Player")
const nearestPlayer = this.findNearestPlayer(playerGroup?.getAllActive() || [])
if (nearestPlayer) {
this.moveToward(nearestPlayer.getPosition())
} else {
this.patrol() // Fallback behavior
}
}
}
```
### 5. Clean Up Resources
```typescript
// GOOD - Proper cleanup
class ParticleEffect extends GameObject {
private particles: Particle[] = []
destroy(): void {
// Clean up resources before destruction
for (const particle of this.particles) {
particle.dispose()
}
this.particles = []
super.destroy()
}
}
```
The Game Object system provides a robust foundation for creating complex, interactive games while maintaining deterministic behavior and clean architecture. By following these patterns and best practices, you can build scalable game systems that are easy to maintain and extend.