Team1‐Enemy System — Interface Contract - UQcsse3200/2026-studio-3 GitHub Wiki
Team 1 | Skeleton delivered (placeholder implementations) | Issues: #1, #17
This page describes how other teams interact with the enemy system. The method signatures are final — you can write your calling code against them now, and they won't change when the real implementations land.
Enemies are Entity objects composed from Components, and are created through EnemyFactory.
Enemies are not an inheritance hierarchy. This project uses the engine's ECS architecture, so there is no Enemy base class and no normal/elite/boss subclasses. The tier is just a tier field in the config. This follows the same pattern as NPCFactory and PlayerFactory.
It's simpler on the calling side: you only ever deal with Entity and don't need to distinguish between enemy types.
A complete combat round:
// Create
Entity enemy = EnemyFactory.create("lesser_shade");
// Start of round: the enemy decides and telegraphs its intent (for the UI to display)
EnemyIntent intent = enemy.getComponent(EnemyBehaviourComponent.class).rollIntent();
// The enemy acts
enemy.getComponent(EnemyBehaviourComponent.class).executeIntent(playerEntity);
// A card deals damage
enemy.getComponent(EnemyStatsComponent.class).takeDamage(8);
// Check whether it's still alive
boolean alive = enemy.getComponent(EnemyStatsComponent.class).isAlive();EnemyFactory is the only entry point for creating enemies.
| Method | Description |
|---|---|
static Entity create(String id) |
Creates an enemy by id. Unknown ids fall back to a default enemy rather than throwing |
static Entity create(EnemyConfig config) |
Creates an enemy from a given config |
static List<String> availableEnemies() |
Ids of every enemy in the roster |
// Normal use
Entity enemy = EnemyFactory.create("lesser_shade");
// List available enemies (for filtering during level generation)
List<String> ids = EnemyFactory.availableEnemies();
// Supply a config directly — useful for tests or runtime stat adjustments
EnemyConfig config = new EnemyConfig();
config.health = 40;
Entity custom = EnemyFactory.create(config);EnemyStatsComponent extends CombatStatsComponent and handles an enemy's health, attack, armour and damage resolution.
| Method | Description |
|---|---|
void takeDamage(int damage) |
Takes damage, depleting armour before health. Non-positive values are ignored |
boolean isAlive() |
Whether the enemy is still alive |
int getMaxHealth() |
Maximum health |
int getArmour() |
Current armour |
void setArmour(int armour) |
Sets armour, clamped to 0 |
void addArmour(int amount) |
Adds armour |
The inherited getHealth() / setHealth() / getBaseAttack() from CombatStatsComponent are also available.
EnemyStatsComponent stats = enemy.getComponent(EnemyStatsComponent.class);
stats.takeDamage(8); // a card deals 8 damage
stats.addArmour(5); // a defend intent resolves, granting 5 armour
if (!stats.isAlive()) {
// the enemy has been defeated
}How armour resolves: armour is consumed first, and only the remainder reduces health.
20 health, 5 armour, takes 8 damage
-> armour absorbs 5, armour drops to 0
-> the remaining 3 reduce health, leaving 17
EnemyBehaviourComponent decides what an enemy does each round and telegraphs it to the player at the start of the round.
| Method | Description |
|---|---|
EnemyIntent rollIntent() |
Decides and telegraphs the intent for the coming round, returning it. Call at the start of the round |
EnemyIntent getCurrentIntent() |
Reads the current intent without changing state. Returns UNKNOWN before combat starts |
void executeIntent(Entity target) |
Resolves the current intent. Call when the enemy acts |
String getBehaviourId() |
The behaviour pattern identifier |
EnemyBehaviourComponent behaviour = enemy.getComponent(EnemyBehaviourComponent.class);
// Start of round: decide the intent, which the UI then displays
EnemyIntent intent = behaviour.rollIntent();
// After the player's turn: the enemy acts
behaviour.executeIntent(playerEntity);rollIntent() and executeIntent() are meant to be called as a pair: telegraph, let the player decide, then resolve. That loop is where the strategy in this genre comes from.
EnemyIntent is an enemy's telegraphed action. It is immutable.
| Method | Description |
|---|---|
IntentType getType() |
The intent type |
int getValue() |
The magnitude, meaning depends on the type (damage / armour) |
static EnemyIntent attack(int damage) |
Builds an attack intent |
static EnemyIntent defend(int armour) |
Builds a defend intent |
static EnemyIntent unknown() |
Builds an unknown intent |
equals / hashCode / toString are implemented, so it's safe in collections and assertions. toString() produces output like ATTACK(6) — for debugging only, don't display it to the player.
| Value | Description |
|---|---|
ATTACK |
Deals damage to the player |
DEFEND |
Grants the enemy armour |
BUFF |
Strengthens the enemy (see Q2) |
DEBUFF |
Weakens the player (see Q2) |
UNKNOWN |
Intent is hidden or not yet decided |
NORMAL / ELITE / BOSS
EnemyConfig holds an enemy's configuration data, loaded from configs/enemies.json.
| Field | Type | Default | Description |
|---|---|---|---|
id |
String | "unknown" |
Unique identifier |
name |
String | "Unknown Enemy" |
Display name |
health |
int | 1 | Health |
baseAttack |
int | 0 | Base attack |
armour |
int | 0 | Armour |
tier |
EnemyTier | NORMAL |
Tier |
behaviour |
String | "cycle_attack_defend" |
Behaviour pattern identifier |
When writing the JSON (relevant to #18):
-
tiervalues must be uppercase, matching the enum constant names exactly ("NORMAL", not"normal"). libGDX matches enums strictly byEnum#name(), so a mismatch fails silently and the field keeps its default. - Fields are matched by name via reflection. A misspelled or missing field won't raise an error — it will silently use the default from the table above.
Enemy entities broadcast state changes through the event system. The UI can listen to these rather than polling.
| Event | Payload | Fired when |
|---|---|---|
enemyDamaged |
int health actually lost |
Damage gets through armour and reduces health |
enemyDefeated |
none | Health reaches 0 |
intentChanged |
EnemyIntent |
After rollIntent() is called |
updateHealth |
int current health |
Health changes (inherited from CombatStatsComponent) |
enemy.getEvents().addListener("enemyDefeated", this::onEnemyDefeated);
enemy.getEvents().addListener("enemyDamaged", (Integer amount) -> showDamageNumber(amount));
enemy.getEvents().addListener("intentChanged", (EnemyIntent i) -> updateIntentIcon(i));Note that enemyDamaged carries the health actually lost, not the raw damage — anything absorbed by armour isn't counted.
These are placeholder implementations. The behaviour below isn't final, but the signatures are.
| Item | Current state | Landing in |
|---|---|---|
| Enemy roster | Empty. Every id misses and returns a fully-default enemy (1 health / 0 attack / 0 armour). availableEnemies() returns an empty list |
#18 |
| Behaviour patterns |
rollIntent() always returns "attack for 5" and ignores the behaviour field |
#20 |
| BUFF / DEBUFF |
executeIntent() only handles ATTACK and DEFEND; other types are skipped silently |
See Q2 |
| Enemies attacking the player | Deals no damage | See Q1 |
| Rendering | Enemy entities carry only the stats and behaviour components — no texture or animation | #21 |
Leaving rendering out is deliberate: attaching a TextureRenderComponent requires ResourceService to have loaded the texture, which crashes unit tests in a headless environment.
Three items need cross-team agreement. All of them involve Team 7.
EnemyBehaviourComponent.executeIntent() currently looks up EnemyStatsComponent, since the player-side stats component doesn't exist yet. Passing a player entity means getComponent() returns null, the null check catches it, and the call fails silently.
As a result, enemies currently deal no damage to the player. Team 3 can't verify the enemy to player damage path until this is resolved.
We need Team 7 to confirm:
- The class name of the player stats component
- The signature of its damage method (ours is
takeDamage(int); it'd be good to keep both sides consistent)
IntentType already reserves BUFF and DEBUFF, but EnemyIntent only carries a type and a value, which can't express which effect or how many turns it lasts.
Team 7's #2 includes a status effect system, but the description only covers the player. Status effects on enemies aren't claimed by any ticket.
We need to confirm:
- Is Team 7's status effect system player-only, or shared between player and enemies?
- If shared, what does the interface look like?
Until then, EnemyIntent's payload format stays as it is.
Currently on: the Feature-1 branch
A merge into main is in progress. Until it lands, pull from Feature-1 if you need it; once it's in, please use main and don't merge team branches into each other.
Contact: @mye10086 (Team 1)
- Enemy System - Design Notes — Team 1 internal design decisions (to be written)
- Entity Component System (ECS)
- Components
- Event System