Building a Generic Rules System for a Web Based 2d Game Engine

Explore the process of designing a versatile and scalable rules system for a web-based 2D game engine. This article delves into the principles of creating a generic architecture that can adapt to various game types, ensuring flexibility and efficiency in development. Perfect for creators looking to build robust game worlds, writers with vast stories to tell, or developers looking to simplify their gaming experience.

Terminal output for a text based game depicting a battle between two characters

The Challenge: Building for Multiple Game Types

When I set out to build a web-based 2D game engine, I quickly realized that the real challenge wasn't rendering sprites or handling input—it was creating a system flexible enough to support radically different game types. A turn-based RPG has completely different rules from a real-time strategy game, which differs entirely from a puzzle platformer.

The solution? A generic rules system that could adapt to any game type while maintaining performance and simplicity.

This is Where Inheritance is Your Friend

The seemingly dreaded JavaScript class keyword becomes your best ally when building extensible game systems. Despite the functional programming trend in modern JavaScript, object-oriented patterns shine in game development where entities naturally inherit behaviors and properties.

Let's start with a base Item class that establishes the foundation for all game objects:

export default class Item {
  name: string
  weight: number
  value: number

  constructor(name: string, weight: number, value: number) {
    this.name = name
    this.weight = weight
    this.value = value
  }

  describe(): string {
    return `${this.name} weighs ${this.weight} lbs and is worth ${this.value} gold pieces.`
  }

  // Generic method that can be overridden
  use(character: Character): GameEffect | null {
    return null // Base items can't be used
  }

  // Hook for custom behaviors
  onPickup(character: Character): void {
    // Override in subclasses for special pickup effects
  }
}

Extending the Base: Specialized Item Types

Now we can create specialized items that inherit from our base class:

class Weapon extends Item {
  damage: number
  durability: number
  weaponType: WeaponType

  constructor(name: string, weight: number, value: number, damage: number, weaponType: WeaponType) {
    super(name, weight, value)
    this.damage = damage
    this.durability = 100
    this.weaponType = weaponType
  }

  use(character: Character): GameEffect {
    return new AttackEffect(this.damage, this.weaponType)
  }

  describe(): string {
    return `${super.describe()} It deals ${this.damage} damage and is ${this.durability}% intact.`
  }
}

class Potion extends Item {
  healAmount: number
  potionType: PotionType

  constructor(name: string, weight: number, value: number, healAmount: number, type: PotionType) {
    super(name, weight, value)
    this.healAmount = healAmount
    this.potionType = type
  }

  use(character: Character): GameEffect {
    return new HealEffect(this.healAmount, this.potionType)
  }

  onPickup(character: Character): void {
    if (this.potionType === PotionType.CURSED) {
      character.applyEffect(new CurseEffect())
    }
  }
}

The Rules Engine: Making It All Work Together

The magic happens in the rules engine—a system that coordinates all these objects and their interactions:

class GameRulesEngine {
  private rules: Map<string, Rule> = new Map()
  private effects: GameEffect[] = []

  addRule(name: string, rule: Rule): void {
    this.rules.set(name, rule)
  }

  processAction(action: GameAction): GameResult {
    const applicableRules = this.findApplicableRules(action)
    let result = new GameResult(action)

    for (const rule of applicableRules) {
      result = rule.apply(result, this.gameState)
    }

    this.processEffects(result)
    return result
  }

  private findApplicableRules(action: GameAction): Rule[] {
    return Array.from(this.rules.values())
      .filter(rule => rule.appliesTo(action))
      .sort((a, b) => a.priority - b.priority)
  }
}

Adapting to Different Game Types

Here's where the system's flexibility really shines. The same base classes can support vastly different game mechanics:

Turn-Based RPG Rules:

class TurnBasedRule implements Rule {
  appliesTo(action: GameAction): boolean {
    return action.type === ActionType.COMBAT
  }

  apply(result: GameResult, gameState: GameState): GameResult {
    // Ensure actions happen in turn order
    if (!gameState.isPlayerTurn && result.actor.type === ActorType.PLAYER) {
      return result.withError("Not your turn!")
    }
    return result
  }
}

Real-Time Strategy Rules:

class ResourceManagementRule implements Rule {
  appliesTo(action: GameAction): boolean {
    return action.requiresResources
  }

  apply(result: GameResult, gameState: GameState): GameResult {
    const cost = action.getResourceCost()
    if (!gameState.player.hasResources(cost)) {
      return result.withError("Insufficient resources")
    }
    
    gameState.player.deductResources(cost)
    return result
  }
}

Performance Considerations

Web-based games face unique performance challenges. The rules engine must be efficient enough to handle real-time gameplay:

class OptimizedRulesEngine extends GameRulesEngine {
  private ruleCache: Map<string, Rule[]> = new Map()

  processAction(action: GameAction): GameResult {
    const cacheKey = action.getCacheKey()
    
    if (!this.ruleCache.has(cacheKey)) {
      this.ruleCache.set(cacheKey, this.findApplicableRules(action))
    }

    const rules = this.ruleCache.get(cacheKey)!
    return this.applyRules(rules, action)
  }

  // Use requestAnimationFrame for smooth gameplay
  private processEffectsAsync(effects: GameEffect[]): void {
    const processChunk = (startIndex: number) => {
      const endIndex = Math.min(startIndex + 10, effects.length)
      
      for (let i = startIndex; i < endIndex; i++) {
        this.processEffect(effects[i])
      }

      if (endIndex < effects.length) {
        requestAnimationFrame(() => processChunk(endIndex))
      }
    }

    requestAnimationFrame(() => processChunk(0))
  }
}

Handling Edge Cases and Extensibility

A robust rules system must handle unexpected interactions gracefully:

interface Rule {
  name: string
  priority: number
  appliesTo(action: GameAction): boolean
  apply(result: GameResult, gameState: GameState): GameResult
  
  // Allow rules to prevent other rules from applying
  blocksRules?: string[]
  
  // Allow rules to depend on other rules
  requiresRules?: string[]
}

class ConditionalRule implements Rule {
  constructor(
    public name: string,
    public priority: number,
    private condition: (action: GameAction, gameState: GameState) => boolean,
    private effect: (result: GameResult, gameState: GameState) => GameResult
  ) {}

  appliesTo(action: GameAction): boolean {
    return this.condition(action, gameState)
  }

  apply(result: GameResult, gameState: GameState): GameResult {
    return this.effect(result, gameState)
  }
}

Testing the System

A generic rules system requires comprehensive testing to ensure reliability across different game types:

describe('GameRulesEngine', () => {
  let engine: GameRulesEngine
  let gameState: GameState

  beforeEach(() => {
    engine = new GameRulesEngine()
    gameState = new GameState()
  })

  test('applies rules in priority order', () => {
    engine.addRule('high-priority', new MockRule('high', 1))
    engine.addRule('low-priority', new MockRule('low', 10))

    const action = new GameAction(ActionType.TEST)
    const result = engine.processAction(action)

    expect(result.appliedRules).toEqual(['high', 'low'])
  })

  test('handles rule conflicts gracefully', () => {
    const blockingRule = new MockRule('blocker', 1, ['blocked'])
    const blockedRule = new MockRule('blocked', 2)

    engine.addRule('blocker', blockingRule)
    engine.addRule('blocked', blockedRule)

    const result = engine.processAction(new GameAction(ActionType.TEST))
    expect(result.appliedRules).not.toContain('blocked')
  })
})

Real-World Applications

This architecture has proven effective across multiple game types I've built:

  • Text-based RPGs where complex item interactions drive the narrative
  • Strategy games requiring resource management and unit coordination
  • Puzzle games where rule combinations create emergent gameplay

The key insight is that games are fundamentally rule-driven systems. By creating a flexible, extensible rules engine, you're not just building a game—you're building a platform for infinite game possibilities.

Conclusion

Building a generic rules system for a web-based 2D game engine taught me that the best abstractions come from understanding the commonalities between seemingly different systems. Whether you're managing inventory in an RPG or resources in an RTS, the underlying patterns are remarkably similar.

The investment in creating a robust, extensible foundation pays dividends throughout development. Instead of hardcoding game logic, you're composing rules that can be mixed, matched, and modified to create entirely new gameplay experiences.

For developers embarking on similar projects, my advice is simple: start with the rules, not the graphics. A solid rules engine will carry your game much further than flashy visuals ever could.