AppleyAppley Docs
Docs/Optimization

Custom Components

Build reusable game logic components that attach to any object.

39 min read Advanced Updated 2026-07-01 v2.0 ~170 min to complete

Introduction

Professional engines use component-based architecture — attach Health, AI, and Inventory components to any object. This guide explains how to design composable components with clean interfaces — then builds custom components in Appley.

Advanced Guide 25 of 25

This is part of the Advanced Documentation collection. Category: Optimization. Estimated completion: 170 minutes. Difficulty: Advanced/Professional.

Screenshot

Custom Components
Custom Components

How Professional Games Do It

1

D

e

2

E

a

3

U

s

4

I

n

5

I

m

6

C

o

7

S

e

8

C

o

9

S

u

10

A

11

P

o

12

F

o

FluxScript Implementation

Here is how to build this system in Appley using FluxScript (Appley Scripting):

Custom Components — FluxScript
// Custom component system with messaging and serialization

// === Health Component ===
const HealthComponent = {
  type: "Health",
  properties: {
    maxHealth: { type: "number", default: 100, serialize: true },
    currentHealth: { type: "number", default: 100, serialize: true },
    invulnerableTime: { type: "number", default: 0, serialize: false }
  },
  dependencies: [],

  onStart: (obj) => {
    this.currentHealth = this.maxHealth
    this.invulnerableTime = 0
    emit("healthInitialized", { obj, health: this.currentHealth })
  },

  onUpdate: (obj, dt) => {
    if this.invulnerableTime > 0 {
      this.invulnerableTime -= dt
    }
  },

  onMessage: (obj, msg, data) => {
    if msg == "takeDamage" {
      this.takeDamage(obj, data.amount, data.type)
    }
    if msg == "heal" {
      this.heal(obj, data.amount)
    }
    if msg == "getHealth" {
      return this.currentHealth
    }
  },

  takeDamage: (obj, amount, damageType) => {
    if this.invulnerableTime > 0 return
    this.currentHealth -= amount
    this.invulnerableTime = 0.5
    obj.emit("healthChanged", { health: this.currentHealth, max: this.maxHealth })
    if this.currentHealth <= 0 {
      obj.emit("died", { cause: damageType })
      obj.sendMessage("onDeath", {})
    }
  },

  heal: (obj, amount) => {
    this.currentHealth = math.min(this.currentHealth + amount, this.maxHealth)
    obj.emit("healthChanged", { health: this.currentHealth, max: this.maxHealth })
  },

  onDestroy: (obj) => {
    // Cleanup
  }
}

// === AI Controller Component ===
const AIControllerComponent = {
  type: "AIController",
  properties: {
    behaviorType: { type: "string", default: "patrol", serialize: true },
    detectionRange: { type: "number", default: 15, serialize: true },
    attackRange: { type: "number", default: 2, serialize: true },
    moveSpeed: { type: "number", default: 4, serialize: true },
    state: { type: "string", default: "idle", serialize: false },
    target: { type: "object", default: null, serialize: false }
  },
  dependencies: ["Health"],  // Requires Health component

  onStart: (obj) -> {
    // Check dependencies
    if obj.getComponent("Health") == null {
      print("ERROR: AIController requires Health component on " + obj.name)
      return
    }
    this.state = "idle"
  },

  onUpdate: (obj, dt) -> {
    switch(this.state) {
      case "idle": this.updateIdle(obj, dt)
      case "chase": this.updateChase(obj, dt)
      case "attack": this.updateAttack(obj, dt)
      case "flee": this.updateFlee(obj, dt)
    }
  },

  onMessage: (obj, msg, data) -> {
    if msg == "onDeath" {
      this.state = "dead"
      obj.setActive(false)
    }
  },

  updateIdle: (obj, dt) -> {
    player = scene.find("Player")
    if math.distance(obj.position, player.position) < this.detectionRange {
      if this.canSee(obj, player) {
        this.state = "chase"
        this.target = player
        obj.emit("aiAlerted", { target: player })
      }
    }
  },

  updateChase: (obj, dt) -> {
    if this.target == null { this.state = "idle"; return }
    dist = math.distance(obj.position, this.target.position)
    if dist > this.detectionRange * 2 {
      this.state = "idle"
      this.target = null
      return
    }
    if dist <= this.attackRange {
      this.state = "attack"
      return
    }
    // Move towards target
    dir = math.normalize(this.target.position - obj.position)
    obj.move(dir * this.moveSpeed * dt)
  },

  updateAttack: (obj, dt) -> {
    if this.target == null { this.state = "idle"; return }
    dist = math.distance(obj.position, this.target.position)
    if dist > this.attackRange * 1.5 {
      this.state = "chase"
      return
    }
    // Attack cooldown
    if this.attackCooldown <= 0 {
      this.target.sendMessage("takeDamage", { amount: 10, type: "melee" })
      this.attackCooldown = 2.0
    }
    this.attackCooldown -= dt
  },

  canSee: (obj, target) -> {
    dir = math.normalize(target.position - obj.position)
    hit = physics.raycast(obj.position + vec3(0,1,0), dir, this.detectionRange)
    return hit == null || hit.object == target
  }
}

// === Register components ===
on start {
  componentManager.register(HealthComponent)
  componentManager.register(AIControllerComponent)
}

// === Usage: create an enemy with components ===
on start {
  enemy = scene.create("goblin")
  enemy.addComponent("Health", { maxHealth: 50 })
  enemy.addComponent("AIController", {
    behaviorType: "patrol",
    detectionRange: 12,
    attackRange: 1.5,
    moveSpeed: 5
  })

  // Components communicate via messages
  // When AI attacks player:
  //   target.sendMessage("takeDamage", { amount: 10 })
  //   -> Health component receives and processes

  // When enemy dies:
  //   Health emits "died" -> AI receives "onDeath" -> sets state to "dead"
}

// === Component pooling for bullets ===
const BulletComponent = {
  type: "Bullet",
  properties: {
    damage: { type: "number", default: 25, serialize: true },
    speed: { type: "number", default: 100, serialize: true },
    lifetime: { type: "number", default: 3, serialize: false }
  },

  onReset: (obj) -> {
    // Called when reused from pool
    this.lifetime = 3
    obj.setVisible(true)
    obj.setActive(true)
  },

  onUpdate: (obj, dt) -> {
    this.lifetime -= dt
    if this.lifetime <= 0 {
      obj.returnToPool()  // Instead of destroy
      return
    }
    obj.move(obj.forward * this.speed * dt)
  },

  onCollision: (obj, other) -> {
    other.sendMessage("takeDamage", { amount: this.damage, type: "bullet" })
    effects.spawn("impact", obj.position)
    obj.returnToPool()
  }
}

Node Graph Equivalent

The same system implemented as a Node Graph in Appley Node Scripting:

Custom Components — Node Graph
[Component: Health]
   properties: maxHealth, currentHealth (serialized)
   onStart --> [Init: currentHealth = maxHealth]
   onMessage:
      "takeDamage" --> [Check: invulnerable?]
                      --> [Reduce: health]
                      --> [Emit: "healthChanged"]
                      --> [Branch: health <= 0] --> [Emit: "died"]
      "heal" --> [Increase: health, clamped]

[Component: AIController]
   dependencies: ["Health"]
   onStart --> [Check: Health exists?]
   onUpdate:
      idle --> [Check: player in range + canSee] --> [chase]
      chase --> [Move towards target] --> [attack range? -> attack]
      attack --> [sendMessage: takeDamage] --> [cooldown]
      flee --> [Move away from target]
   onMessage: "onDeath" --> [state = dead, deactivate]

[Usage]
   enemy = scene.create("goblin")
   --> [addComponent: Health (maxHealth: 50)]
   --> [addComponent: AIController (patrol, range: 12)]
   --> [Components communicate via sendMessage]
        AI.attack --> target.sendMessage("takeDamage")
        --> Health receives and processes
   --> [Health.died --> emit "died"]
        --> AI receives "onDeath" --> state = dead

[Component: Bullet (Pooled)]
   onReset --> [Reset: lifetime, visible, active]
   onUpdate --> [Move forward, decrement lifetime]
      --> [Branch: lifetime <= 0] --> [returnToPool]
   onCollision --> [sendMessage: takeDamage] --> [returnToPool]

Professional Optimization Tips

How AAA studios optimize these systems — applicable to your Appley project:

  • Use composition over inheritance. Instead of deriving Enemy from Character, attach Health + AI + Inventory components. This is more flexible — a merchant has Health + Inventory but different AI. Components are reusable across object types.
  • Use message-based inter-component communication. Components on the same object communicate via sendMessage. This decouples them — AI does not import Health. Adding/removing components does not break others.
  • Declare component dependencies. If AIController requires Health, check in onStart. Without dependencies, components fail silently — AI attacks a dead enemy because no Health component exists to process death.
  • Pool components for frequently created/destroyed objects. Bullets, particles, enemies. Implement onReset() to clear state for reuse. Pooling eliminates allocation overhead and GC spikes.

Common Mistakes

Tight coupling between components

AI directly calling Health.takeDamage() creates coupling. If Health is removed, AI crashes. Use sendMessage — the message is ignored if no component handles it. This makes components truly composable.

Not checking dependencies in onStart

If AIController requires Health but none exists, it should error immediately. Without checks, the AI runs but attacks do nothing (no Health to process damage). Silent failures are hard to debug.