Custom Components
Build reusable game logic components that attach to any object.
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
Screenshot
How Professional Games Do It
D
e
E
a
U
s
I
n
I
m
C
o
S
e
C
o
S
u
A
P
o
F
o
FluxScript Implementation
Here is how to build this system in Appley using FluxScript (Appley Scripting):
// 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:
[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.
