Repository navigation
Potions
Potions are consumable items the player picks up as loot and drinks for a temporary or instant effect. Sprint 2 extended the Sprint 1 consumable system (see Loot Generation - Consumable Items) rather than replacing it:
- two new potions, Regeneration and Resistance, bringing the total to five
- a deliberate stacking rule, replacing accidental multiplication of same-stat potions
- every potion has its own sprite (previously the Damage potion drew
Poison.pngand the Speed potion drewStrength.png) - a fix so buff potions actually work in the running game: the player was missing the component that applies them
Task: Potions and Loot Spawning (#90), part of feature #87. Delivered in PR #144, with loot placement improved in PR #174.
- Pick it up by walking into it. Potions on the ground float gently up and down so they read as collectable.
- Drink it by pressing the number key (1 to 5) for the inventory slot it is in. The key selects that slot and uses the item in it.
- One potion is removed from the stack only if it had an effect. A health potion at full health, or a weaker potion while a stronger one of the same kind is active, stays in the inventory instead of being wasted.
Values are for a tier 1 potion, loaded from configs/consumables.json.
| Potion | ConsumableType |
Sprite | Effect | Tier 1 value | Duration | Working in game now? |
|---|---|---|---|---|---|---|
| Health Potion | HEALTH_POTION |
health_potion.png |
Restores health straight away | +25 health | Instant | Yes |
| Regeneration Potion | REGENERATION |
regeneration_potion.png |
Restores health once per second | +5 health per second (30 total) | 6 s | Yes |
| Damage Potion (Strength) | DAMAGE_BUFF |
strength_potion.png |
Multiplies the player's damage | x1.5 | 10 s | Melee only (see Integration) |
| Speed Potion | SPEED_BUFF |
speed_potion.png |
Multiplies movement speed | x1.5 | 10 s | Not yet: movement does not read it |
| Resistance Potion | RESISTANCE |
resistance_potion.png |
Multiplies the damage the player takes | x0.7 (30% less damage) | 10 s | Not yet: damage handling does not read it |
Health is always capped at the player's maximum health, for both the Health and Regeneration potions.
Instantly restores health, up to the player's maximum. Used at full health it has no effect, so it stays in the inventory.
Restores health gradually instead of all at once: one tick every second for the potion's duration. Ticks are timed from the game clock rather than by counting frames, so a slow frame applies every tick it skipped instead of losing them. It can be drunk at full health, because the healing arrives over several seconds and is still useful going into a fight.
Multiplies the player's damage while active. The multiplier is available from PlayerBuffComponent.getDamageMultiplier() for the weapon damage system to use.
Multiplies movement speed while active, available from PlayerBuffComponent.getSpeedMultiplier().
Multiplies incoming damage by a value below 1, so the player takes less damage. For resistance, a lower multiplier is the stronger potion. It is available from PlayerBuffComponent.getIncomingDamageMultiplier() for whatever applies damage to the player, alongside the shield.
Higher-tier potions are generated from the same config values by ConsumableGenerator:
- Healing (Health, Regeneration) multiplies by the tier.
-
Multipliers grow by their distance from 1.0:
1 + (configured - 1) x tier. A 1.5x buff becomes 2.0x at tier 2, and a 0.7x resistance becomes 0.4x at tier 2. - Resistance is floored at 0.2, so no tier can make the player immune to damage.
- Potions above tier 1 get the tier in their name (for example
Health Potion (Tier 2)). The inventory stacks items by name, so a weak potion never merges with a strong one.
| Potion | Tier 1 | Tier 2 | Tier 3 |
|---|---|---|---|
| Health | +25 | +50 | +75 |
| Regeneration (per second) | +5 | +10 | +15 |
| Damage / Strength | x1.5 | x2.0 | x2.5 |
| Speed | x1.5 | x2.0 | x2.5 |
| Resistance | x0.7 | x0.4 | x0.2 (floor) |
Timed stat potions (Damage, Speed, Resistance) are tracked by PlayerBuffComponent. Regeneration is not a stat multiplier, so it has its own component, PlayerRegenComponent, which follows the same rules.
At most one buff is active per stat, and the strongest one wins.
| Situation | Result |
|---|---|
| Drink the same potion again | Its timer restarts; it does not add a second buff |
| Drink a stronger potion of the same kind | It replaces the weaker one |
| Drink a weaker potion while a stronger one is active | Rejected, and the potion stays in the inventory |
| Drink potions of different kinds | They are independent and all apply together |
In Sprint 1, two potions of the same stat multiplied together (two 1.5x Speed potions gave 2.25x), which produced values the game was never balanced for. That behaviour was removed deliberately.
Each buff stores the time it ends. update() removes any buff whose end time has passed and recalculates the stats as "unbuffed value x the active buff on that stat". Starting and ending a buff use that same rule, so a buff never has to work out how to undo itself.
The player's unbuffed attack is read at the moment a damage buff starts, not once when the game starts. That way an attack value changed by something else (an upgrade, for example) is not overwritten when the potion wears off.
Other systems, such as a future buff timer UI, can listen for:
| Event | Triggered by | Carries |
|---|---|---|
buffApplied / buffExpired
|
PlayerBuffComponent |
the ActiveBuff (stat, magnitude, duration, remaining time) |
regenStarted / regenExpired
|
PlayerRegenComponent |
heal per tick / nothing |
itemConsumed |
ConsumableUseComponent |
the ConsumableItem that was used |
flowchart TD
A[Player presses 1-5] --> B["useItem event with the slot number"]
B --> C[ConsumableUseComponent reads that inventory slot]
C --> D{Is it a potion?}
D -- no --> X[Nothing happens]
D -- yes --> E[The potion's effect is applied]
E --> F{Did the effect do anything?}
F -- yes --> G[One potion is removed and itemConsumed is triggered]
F -- no --> H[The potion stays in the inventory]
E -.-> I["HealEffect: heals now"]
E -.-> J["RegenerationEffect: starts PlayerRegenComponent"]
E -.-> K["BuffEffect: applies a buff in PlayerBuffComponent"]
| Class | Responsibility |
|---|---|
ConsumableItem |
A potion in the inventory: its type, its effect and its sprite |
ConsumableEffect |
What a potion does. Implemented by HealEffect, BuffEffect and RegenerationEffect
|
ConsumableGenerator |
Builds a potion of a given type and tier from configs/consumables.json
|
ConsumableUseComponent |
On the player. Uses the potion in a slot, and holds the player's maximum health |
PlayerBuffComponent |
On the player. Tracks timed stat buffs, applies the stacking rule and expires them |
PlayerRegenComponent |
On the player. Heals once per second while a regeneration potion is active |
BuffStat |
The stats a buff can change: DAMAGE, SPEED, RESISTANCE
|
ActiveBuff |
A read-only record of one running buff, for UI or inspection |
PlayerFactory adds ConsumableUseComponent, PlayerBuffComponent and PlayerRegenComponent to the player. Without them a potion has nothing to apply itself to and silently does nothing. That was the reason the buff potions did not work at the start of Sprint 2.
The potion components expose simple values for other systems to read, so potions never need to edit classes owned by other teams.
| Value | Read from | Should be used by | Status |
|---|---|---|---|
| Damage multiplier | PlayerBuffComponent.getDamageMultiplier() |
Weapon damage (melee and projectiles) |
Melee works through a temporary bridge: the buff also scales CombatStatsComponent.baseAttack, which player melee hits use. Arrows and daggers deal weapon.getDamage() directly, so they are not boosted until they multiply by this value. |
| Speed multiplier | PlayerBuffComponent.getSpeedMultiplier() |
Player movement | Not wired yet. Movement needs to multiply its target speed by this value. |
| Incoming damage multiplier | PlayerBuffComponent.getIncomingDamageMultiplier() |
Whatever applies damage to the player, alongside the shield | Not wired yet. Incoming damage needs to be multiplied by this value. |
| Maximum health | ConsumableUseComponent.maxHealthOf(entity) |
Anything that heals | Used by both healing potions. CombatStatsComponent has no maximum health field. |
Once weapon damage multiplies by getDamageMultiplier() everywhere, the setBaseAttack bridge in PlayerBuffComponent.recalculateStats() can be deleted.
Each potion has its own 32x32 sprite in images/potions/:
| Potion | File |
|---|---|
| Health | health_potion.png |
| Damage / Strength | strength_potion.png |
| Speed | speed_potion.png |
| Regeneration | regeneration_potion.png |
| Resistance | resistance_potion.png |
The sprite path lives on the ConsumableType itself, so nothing else hardcodes it. Potions on the ground are created by LootFactory and drawn with BobbingTextureRenderComponent. The sprites must be listed in the texture arrays of LevelGameArea and ForestGameArea so they are loaded before the level starts.
Where potions appear in a level is covered on Loot Generation Sprint 2.
- Add a value to
ConsumableTypewith its sprite path, and put the sprite inimages/potions/. - Add a
ConsumableConfigfield toConsumableConfigs(and itsget()switch), and the values toconfigs/consumables.json. - Add a case to
ConsumableGenerator.generateConsumable(). - If it does something new, write a class implementing
ConsumableEffect. If it changes a stat, add aBuffStatvalue and a getter onPlayerBuffComponentinstead. - List the sprite in the texture arrays of
LevelGameAreaandForestGameArea.
The loot table and the integration test both loop over every ConsumableType, so a half-added potion fails the tests rather than silently never appearing.
Run with cd source && ./gradlew :core:test.
| Test class | Covers |
|---|---|
PlayerBuffComponentTest |
Buffs applying and expiring, the stacking rule, resistance comparing the other way round, the unbuffed attack being read when a buff starts, buff events |
PlayerRegenComponentTest |
One tick per second, stopping when the duration ends, the maximum health cap, stronger and weaker regeneration, ticks skipped by a slow frame |
ConsumableGeneratorTest |
Tier scaling for every potion, including the resistance floor |
ConsumableEffectIntegrationTest |
Every potion type drunk by a player-shaped entity, so a potion with nothing to apply itself to fails here instead of silently in game |
PlayerFactoryTest |
The real player has every component potions need |
ConsumableItemTest, HealEffectTest, BuffEffectTest, ConsumableUseComponentTest
|
Item data, the health effect, the buff effect, and using an item from the inventory |
- The Speed and Resistance potions have no visible effect until movement and incoming damage read their multipliers (see Integration).
- The Damage potion boosts melee hits only, not arrows or daggers.
- Active buff timers are not shown on screen yet. The events and
getActiveBuffs()are in place for a UI to use.