SimpleJRPG
A lean runtime battle API for JRPGs
Your rules, your formulas.
Philosophy
SimpleJRPG orchestrates battles. You bring your own stats, formulas, UI, and data storage.
There are no opinionated damage formulas, no built-in status effect logic, no UI layer. Instead, SimpleJRPG exposes clean combat verbs and fires events on every action. Your code decides what "Fire" means, how much damage a critical hit deals, and what happens when a character is knocked out. We just make sure the battle flows.
Turn Systems
Five pluggable turn systems ship out of the box. Implement ITurnSystem to build your own.
Classic
DQ-style speed-sorted round-robin. Each round, combatants act in speed order. Simple, predictable, timeless.
ATB
Active Time Battle. Gauges fill over ticks based on speed. When a gauge is full, that combatant acts. No waiting for the whole team.
Timeline / CTB
Charge Time Battle. Actions have cast times that place actors on a timeline. Heavier moves push your next turn further out.
Press Turn
SMT-style team phases. Each team gets a pool of turn points. Exploit weaknesses to gain extra points; miss to lose them.
Action Point
Spend/Save AP system. Bank AP by defending, then spend it for multiple actions in a single turn. Risk and reward.
Features
Instance-Based
No singletons, no statics, no MonoBehaviours. Create a Battle instance, pass in combatants, and go.
Pass-Through Design
Strings, not enums, for all game-specific values. We don't interpret "Fire" or "Poison" — we pass them through. Your system decides what they mean.
Combat Verbs
DealDamage(), Heal(), Kill(), Revive(), ApplyStatus(), ApplyBuff(), Flee() — simple imperative methods.
Events Over Callbacks
Every verb fires an event. Subscribe to OnDamageDealt, OnHealed, OnKO, and more to drive UI, sound, VFX, or logging.
Pluggable Turn Systems
Five built-in implementations of ITurnSystem. Swap between Classic, ATB, Timeline, Press Turn, or Action Point — or write your own.
No Unity Dependencies
Pure C# with noEngineReferences: true. No ScriptableObjects, no MonoBehaviours, no UnityEngine imports. Testable anywhere.
Getting Started
- Import SimpleJRPG from the Unity Asset Store into your project.
- Implement
ICombatanton your character class — provide Name, IsAlive, Team, Speed, TakeDamage(), and Heal(). - Create a
Battleinstance and pass in your list of combatants along with a turn system. - Choose a turn system:
ClassicTurnSystem,ATBTurnSystem,TimelineTurnSystem,PressTurnSystem, orActionPointTurnSystem. - Subscribe to events like
OnDamageDealt,OnHealed, andOnKOto wire up your UI, animations, and game logic.
Installation
Get SimpleJRPG into your Unity project in under a minute.
Import from Unity Asset Store
SimpleJRPG is available on the Unity Asset Store. Follow these steps to add it to your project:
- Open the Unity Editor and navigate to Window > Package Manager.
- In the Package Manager, select My Assets from the dropdown in the top-left.
- Search for SimpleJRPG in your asset list.
- Click Download if you haven't already, then click Import.
- In the Import dialog, ensure all files are selected and click Import.
- SimpleJRPG will appear under
Assets/SimpleJRPG/in your project.
Folder Structure
After importing, you'll find the following structure in your project:
Assets/SimpleJRPG/
├── Runtime/ — Core battle API
├── Demo/ — Interactive demo scenes
├── Documentation/ — This documentation
└── Scenes/ — Generated demo scenes
The Runtime/ folder contains everything you need. The other folders are optional and can be safely deleted if you only want the core API.
Assembly Definition
SimpleJRPG ships with an assembly definition file (SimpleJRPG.asmdef) located in the Runtime/ folder. This assembly definition is configured with noEngineReferences: true, which means:
- Pure C# — No dependency on UnityEngine or any Unity API.
- Testable — You can reference this assembly from a test project and run unit tests with any .NET test framework, no Unity test runner required.
- Portable — The code can be compiled and used outside of Unity entirely, making it easy to share logic between a Unity client and a server, for example.
To reference SimpleJRPG from your own assembly definitions, simply add SimpleJRPG to your asmdef's reference list.
Requirements
- Unity 2021.3+ — Any recent Unity version is supported (2021 LTS or newer).
- No third-party dependencies — SimpleJRPG does not rely on any external packages or plugins.
Quick Start
Get a battle running in four steps.
Step 1: Implement ICombatant
ICombatant is the only interface your characters need to implement.
You own the stats, the properties, the logic. SimpleJRPG just needs a name, alive status,
team index, speed, and methods to take damage and heal.
public class MyCombatant : ICombatant
{
public string Name { get; }
public bool IsAlive => HP > 0;
public int Team { get; }
public float Speed { get; }
public int HP { get; private set; }
public int MaxHP { get; }
public MyCombatant(string name, int hp, float speed, int team)
{
Name = name;
HP = hp;
MaxHP = hp;
Speed = speed;
Team = team;
}
public void TakeDamage(int amount) => HP = Math.Max(0, HP - amount);
public void Heal(int amount) => HP = Math.Min(MaxHP, HP + amount);
}
ICombatant defines.
Step 2: Create a Battle
Create a Battle instance, build your party and enemy lists, then call
Start() with all combatants and a turn system.
var battle = new Battle();
var party = new List<ICombatant>
{
new MyCombatant("Hero", 100, 10f, 0),
new MyCombatant("Mage", 70, 12f, 0),
};
var enemies = new List<ICombatant>
{
new MyCombatant("Goblin", 50, 8f, 1),
new MyCombatant("Orc", 80, 6f, 1),
};
var all = new List<ICombatant>();
all.AddRange(party);
all.AddRange(enemies);
battle.Start(all, new ClassicTurnSystem());
0 is the player party. All other team values are enemies.
CheckBattleEnd() uses this to determine Victory vs. Defeat.
Step 3: Subscribe to Events
Every combat verb fires an event. Subscribe to drive your UI, play sound effects, trigger animations, or log to console — whatever your game needs.
battle.OnDamageDealt += e => Debug.Log($"{e.Source.Name} hits {e.Target.Name} for {e.Amount}!");
battle.OnKO += e => Debug.Log($"{e.Target.Name} was knocked out!");
battle.OnBattleEnd += (b, state) => Debug.Log($"Battle ended: {state}");
Events are the primary way SimpleJRPG communicates what happened. There are no built-in UI components, no logging systems, no animation controllers. You subscribe to exactly the events you care about and handle them however you want.
Step 4: Run the Battle Loop
Ask the battle for the next actor, execute your game logic (pick targets, calculate damage with your own formulas), call combat verbs, end the turn, and check if the battle is over.
while (battle.State == BattleState.WaitingForCommands)
{
ICombatant actor = battle.BeginNextTurn();
// Your game logic: pick targets, calculate damage, etc.
var target = battle.GetEnemies(actor)[0];
int damage = CalculateDamage(actor, target); // Your formula
battle.DealDamage(actor, target, damage, "physical", "none");
battle.EndTurn();
battle.CheckBattleEnd();
}
CalculateDamage() is your function, not ours. SimpleJRPG never calculates
damage, hit rates, or any other formula. You pass in the final number, we apply it and
fire the event.
What's Next
You have a working battle. Here is where to go from here:
- Battle Core — All combat verbs:
DealDamage,Heal,Kill,Revive,ApplyStatus,ApplyBuff,Flee, and the events they fire. - Turn Systems — Alternatives to
ClassicTurnSystem: ATB, Timeline/CTB, Press Turn, and Action Point. - API Reference — Full details on every class, interface, method, and event.
Battle Core
The Battle class is the heart of SimpleJRPG — a plain C# instance that orchestrates combat through lifecycle methods, combat verbs, and events.
Battle Lifecycle
Start()
public void Start(List<ICombatant> combatants, ITurnSystem turnSystem)
Initializes the battle. Pass in your list of combatants and a turn system implementation. Internally, this method:
- Stores the combatants and turn system references.
- Resets the turn counter and battle history.
- Calls
turnSystem.Init(combatants)so the turn system can set up its internal state. - Sets
StatetoBattleState.WaitingForCommands. - Fires the
OnBattleStartevent.
Start() will silently return if the battle state is anything other than NotStarted. You cannot restart a battle instance — create a new one.
EndBattle()
public void EndBattle(BattleState result)
Ends the battle with the given outcome. Pass BattleState.Victory, BattleState.Defeat, or BattleState.Fled. This method:
- Sets
Stateto the given result. - Clears
CurrentActor. - Creates a
BattleResultcontaining the outcome, total turns taken, surviving combatants, and the full battle history. - Fires the
OnBattleEndevent with the battle instance and the resulting state.
If the battle has already ended (state is Victory, Defeat, or Fled), calling EndBattle() again does nothing.
Turn Flow
BeginNextTurn()
public ICombatant BeginNextTurn()
Advances to the next turn. Asks the turn system for the next actor via GetNextActor(), increments the turn counter, sets state to Executing, and fires OnTurnStart. Returns the actor whose turn it is, or null if no actor is available.
Can only be called when state is WaitingForCommands or Executing.
EndTurn()
public void EndTurn()
Ends the current turn. Fires OnTurnEnd, notifies the turn system via OnTurnEnd(), clears CurrentActor, then automatically calls CheckBattleEnd(). If the battle hasn't ended, state returns to WaitingForCommands.
Can only be called when state is Executing.
State Machine
The battle progresses through a simple state machine. All transitions are driven by the methods described above.
Fled is reached via Flee(), which calls EndBattle(BattleState.Fled) directly — it can happen from any active state, not just after CheckBattleEnd().
Combat Verbs
Combat verbs are the imperative methods you call to make things happen in battle. You do the math — we orchestrate the result.
DealDamage
public void DealDamage(ICombatant source, ICombatant target, int amount,
string damageType = "", string element = "", bool isCrit = false)
Deals damage to a single target. The flow is:
- Fires
OnBeforeDamagewith aBeforeDamageEvent. Subscribers can modifyAmount,DamageType,Element,IsCrit, or setCancel = trueto prevent the damage entirely. - If not cancelled, calls
target.TakeDamage(amount)with the (potentially modified) amount. - Fires
OnDamageDealtwith the finalDamageEvent. - If the target is no longer alive, automatically fires
OnKOand notifies the turn system.
There is also a group overload that accepts IList<ICombatant> targets and fires OnGroupDamageDealt after processing all individual hits.
Heal
public void Heal(ICombatant source, ICombatant target, int amount)
Heals a single target. The flow mirrors damage:
- Fires
OnBeforeHealwith aBeforeHealEvent. Subscribers can modifyAmountor setCancel = true. - If not cancelled, calls
target.Heal(amount). - Fires
OnHealedwith the finalHealEvent.
Group overload available — fires OnGroupHealed after all individual heals.
Kill
public void Kill(ICombatant target, ICombatant killer = null)
Forces a KO regardless of the target's current HP. Calls target.TakeDamage(int.MaxValue), then fires OnKO and notifies the turn system. Does nothing if the target is already dead. This bypasses the before-event system — there is no OnBeforeDamage for Kill.
Revive
public void Revive(ICombatant target, int hpAmount)
Revives a dead combatant by calling target.Heal(hpAmount), then notifies the turn system via OnActorAdded() so the revived combatant is re-inserted into the turn order. Fires OnRevived. Does nothing if the target is already alive.
Status & Buff
ApplyStatus / RemoveStatus
public void ApplyStatus(ICombatant source, ICombatant target, string statusId, int duration = -1)
public void RemoveStatus(ICombatant target, string statusId)
ApplyStatus fires OnStatusApplied with a StatusEvent containing the source, target, status ID string, and optional duration. RemoveStatus fires OnStatusRemoved with the target and status ID. Both have group overloads that fire OnGroupStatusApplied.
The statusId is a pass-through string — Battle doesn't know or care what "Poison" or "Blind" means. Your system decides.
ApplyBuff / RemoveBuff
public void ApplyBuff(ICombatant source, ICombatant target, string buffId, float amount, int duration = -1)
public void RemoveBuff(ICombatant target, string buffId)
ApplyBuff fires OnBuffApplied with a BuffEvent containing source, target, buff ID, numeric amount, and optional duration. RemoveBuff fires OnBuffRemoved. Group overloads fire OnGroupBuffApplied.
Same pass-through philosophy — buffId and amount are yours to interpret. "ATK_UP" with amount 1.5 could mean +50% attack, or 1.5 flat — your call.
Flee
public void Flee(ICombatant who)
Fires OnFled with the fleeing combatant, then immediately calls EndBattle(BattleState.Fled). The battle ends — no further actions are possible.
Whether the flee attempt succeeds is your game's decision. Only call Flee() after you've already determined the attempt was successful. If you want a chance-based flee, do your roll first, and only call this if it passes.
CheckBattleEnd
public bool CheckBattleEnd()
Checks whether the battle should end based on which teams still have living combatants. Returns true if the battle ended, false otherwise.
The logic:
- Team 0 = player team. All other team numbers are considered enemies.
- If no enemies are alive (only team 0 remains) —
EndBattle(BattleState.Victory). - If no team 0 members are alive —
EndBattle(BattleState.Defeat).
CheckBattleEnd() is called automatically at two points: inside EndTurn(), and inside HandleKO() (which fires whenever a combatant dies from DealDamage or Kill). You typically don't need to call it yourself, but you can if you want to check mid-turn.
Before-Events
Intercept, modify, or cancel actions before they execute.
Overview
Before-events fire before the action executes, giving subscribers a chance to modify or cancel it. Two before-events exist: BeforeDamageEvent and BeforeHealEvent.
These are classes (not structs) so modifications persist. When a subscriber changes Amount or sets Cancel = true, those changes are visible to the Battle method that fired the event and to all subsequent subscribers.
BeforeDamageEvent
Fired by Battle.DealDamage() before any damage is applied.
| Property | Type | Settable | Description |
|---|---|---|---|
Source |
ICombatant |
No | The combatant dealing damage. |
Target |
ICombatant |
No | The combatant receiving damage. |
Amount |
int |
Yes | The damage amount. Modify this to increase or reduce damage. |
DamageType |
string |
No | Pass-through string for the type of damage (e.g. "Physical", "Magic"). |
Element |
string |
No | Pass-through string for the element (e.g. "Fire", "Ice"). |
IsCrit |
bool |
No | Whether this hit is a critical. |
Cancel |
bool |
Yes | Set to true to cancel the damage entirely. OnDamageDealt will not fire. |
When Cancel is set to true, the damage is not applied and OnDamageDealt does not fire.
BeforeHealEvent
Fired by Battle.Heal() before any healing is applied.
| Property | Type | Settable | Description |
|---|---|---|---|
Source |
ICombatant |
No | The combatant performing the heal. |
Target |
ICombatant |
No | The combatant being healed. |
Amount |
int |
Yes | The heal amount. Modify this to boost or reduce healing. |
Cancel |
bool |
Yes | Set to true to cancel the heal entirely. OnHealed will not fire. |
Same cancellation behavior as BeforeDamageEvent — when Cancel is true, the heal is not applied and OnHealed does not fire.
Example: Dodge System
Subscribe to OnBeforeDamage and cancel the event to implement a dodge mechanic:
battle.OnBeforeDamage += e =>
{
// 20% dodge chance
if (Random.value < 0.2f)
{
e.Cancel = true;
Debug.Log($"{e.Target.Name} dodged the attack!");
}
};
Example: Damage Reduction
Modify Amount to reduce incoming damage when a combatant is defending:
battle.OnBeforeDamage += e =>
{
// Halve damage if target is defending
if (IsDefending(e.Target))
e.Amount /= 2;
};
Example: Healing Boost
Modify Amount on OnBeforeHeal to amplify healing:
battle.OnBeforeHeal += e =>
{
// Double healing if healer has "Amplify" buff
if (HasBuff(e.Source, "Amplify"))
e.Amount *= 2;
};
Amount and Cancel must persist to the Battle method that fired the event. Multiple subscribers can all modify the same event.
Helpers
Multi-target overloads, query methods, battle history, and utility features.
Multi-Target Overloads
DealDamage, Heal, ApplyStatus, and ApplyBuff
all have overloads that accept IList<ICombatant> instead of a single target.
Each individual action still fires its own event. Additionally, a group event fires with aggregated data.
OnGroupDamageDealt(GroupDamageEvent)— contains Source, list of Hits (IReadOnlyList<DamageEvent>), TotalDamageOnGroupHealed(GroupHealEvent)— contains Source, list of Heals (IReadOnlyList<HealEvent>), TotalHealedOnGroupStatusApplied(GroupStatusEvent)— contains Source, list of ApplicationsOnGroupBuffApplied(GroupBuffEvent)— contains Source, list of Applications
var enemies = battle.GetEnemies(actor);
battle.DealDamage(actor, enemies, 50, "magic", "Fire");
// Fires OnDamageDealt for EACH enemy, then OnGroupDamageDealt once
Query Helpers
GetEnemies(combatant) returns all alive combatants NOT on the same team.
GetAllies(combatant, includeSelf) returns alive combatants on the same team.
GetAlive(team) and GetDead(team) filter by team (-1 for all).
var enemies = battle.GetEnemies(hero); // All alive enemies
var allies = battle.GetAllies(hero, true); // All alive allies including hero
var dead = battle.GetDead(0); // Dead party members (team 0)
Battle History
Battle records every event as a BattleHistoryEntry with Sequence number,
TurnNumber, EventType string, and the Event object. Access via
battle.History (IReadOnlyList<BattleHistoryEntry>).
foreach (var entry in battle.History)
Debug.Log($"[Turn {entry.TurnNumber}] {entry.EventType}: {entry.Event}");
RemoveCombatant
Removes a combatant from battle entirely (not just KO). Fires OnCombatantRemoved.
Useful for flee-per-combatant or banish mechanics.
battle.RemoveCombatant(summon); // Completely removes from battle
BattleResult
Created when battle ends via EndBattle() or CheckBattleEnd().
Contains Outcome (BattleState), TotalTurns, Survivors (list of alive combatants),
and full History. Access via battle.Result.
battle.OnBattleEnd += (b, state) =>
{
var result = b.Result;
Debug.Log($"Outcome: {result.Value.Outcome}");
Debug.Log($"Turns: {result.Value.TotalTurns}");
Debug.Log($"Survivors: {result.Value.Survivors.Count}");
};
Turn Systems
Five pluggable turn systems — from classic round-robin to press turns and action points.
ITurnSystem Interface
All turn systems implement ITurnSystem. This is the shared contract that
Battle uses to manage turn order. You can also implement your own.
public interface ITurnSystem
{
void Init(List<ICombatant> combatants);
ICombatant GetNextActor();
IReadOnlyList<ICombatant> GetTimeline();
void OnActorDied(ICombatant combatant);
void OnActorAdded(ICombatant combatant);
void OnTurnEnd();
}
Methods
Init(combatants)— called once when the battle starts. Sets up internal state from the combatant list.GetNextActor()— returns the next combatant who should act, ornullif no one is ready.GetTimeline()— returns a read-only list representing upcoming turn order. Useful for UI previews.OnActorDied(combatant)— notifies the turn system that a combatant died so it can clean up.OnActorAdded(combatant)— notifies the turn system that a combatant was added mid-battle.OnTurnEnd()— called after an actor finishes their turn. Advances internal state.
ClassicTurnSystem
DQ-style speed-sorted round-robin. Simple and predictable.
Constructor
new ClassicTurnSystem() // No parameters
Behavior
- On init (and at the start of each new round), all alive combatants are sorted by
Speeddescending. - Dead combatants are skipped automatically in
GetNextActor(). - Mid-round additions via
OnActorAdded()are appended to the end of the current round. - When all combatants in the round have acted, a new round begins with a fresh speed sort.
Additional Methods
None. ClassicTurnSystem only exposes the ITurnSystem interface.
Usage
var battle = new Battle();
battle.Start(combatants, new ClassicTurnSystem());
var actor = battle.BeginNextTurn(); // Fastest combatant
// ... do action ...
battle.EndTurn();
ATBTurnSystem
Active Time Battle (gauge-based, FF-style). Gauges fill in real time based on speed.
Constructor
new ATBTurnSystem(float gaugeMax = 100f)
gaugeMax— the gauge value a combatant must reach before they can act. Default:100f.
Behavior
- Each combatant has a gauge starting at
0. Tick(deltaTime)fills each alive combatant's gauge bySpeed * deltaTime. Already-full gauges do not overflow.GetNextActor()returns the ready combatant (gauge >= max) with the highest gauge. Ties broken by speed. Returnsnullif no one is ready.- After acting,
OnTurnEnd()resets the actor's gauge to0. - Dead combatants have their gauge removed entirely.
Additional Methods
| Method | Description |
|---|---|
Tick(float deltaTime) | Advances all alive combatants' gauges by Speed * deltaTime. |
IsReady(ICombatant) | Returns true if the combatant's gauge >= max. |
GetGauge(ICombatant) | Returns current gauge value (or 0 if not tracked). |
SetGauge(ICombatant, float) | Sets gauge value directly (e.g. for slow/haste effects). |
HasReadyActor() | Returns true if any alive combatant's gauge >= max. |
Usage
var atb = new ATBTurnSystem(100f);
battle.Start(combatants, atb);
// Game loop
atb.Tick(1f); // Advance time
if (atb.HasReadyActor())
{
var actor = battle.BeginNextTurn();
// ... do action ...
battle.EndTurn();
}
Tick() yourself. This works with Unity's
Time.deltaTime, fixed timesteps, or any custom tick rate.
TimelineTurnSystem
Charge Time Battle (timeline-based, FFT-style). Combatants sit on a tick timeline; lower tick = sooner turn.
Constructor
new TimelineTurnSystem(int defaultActionCost = 100, int baseTick = 100)
defaultActionCost— the default cost added after an action. Default:100.baseTick— base tick value used for initial placement and new combatants. Default:100.
Behavior
- On init, each combatant is placed on the timeline at tick
baseTick / Speed. Faster combatants start closer to 0. GetNextActor()returns the alive combatant with the lowest tick. Ties broken by highest speed, then init order.- After acting, the actor's tick advances by
actionCost(default or overridden viaSetActionCost()). - Heavy actions with higher costs push the actor further down the timeline. Light actions let them act again sooner.
- New combatants added mid-battle are placed at
currentTick + baseTick.
Additional Methods
| Method | Description |
|---|---|
SetActionCost(int cost) | Override the cost for the current action. Call between BeginNextTurn() and EndTurn(). Resets to default each turn. |
GetTick(ICombatant) | Returns the combatant's current tick value on the timeline. |
Usage
var timeline = new TimelineTurnSystem(100, 100);
battle.Start(combatants, timeline);
var actor = battle.BeginNextTurn(); // Lowest tick actor
// Light attack — default cost
battle.EndTurn();
var next = battle.BeginNextTurn();
timeline.SetActionCost(200); // Heavy attack — delays next turn more
battle.EndTurn();
SetActionCost() to make different abilities cost different amounts of time.
A quick jab might cost 50, a powerful spell 300. This creates meaningful tactical decisions
about speed vs. power.
PressTurnSystem
SMT-style press turn system. Teams take turns in phases, spending turn points to act.
Constructor
new PressTurnSystem(int maxPointsPerTeam = 0)
maxPointsPerTeam— cap on points per phase.0= auto (one point per alive team member).
Behavior
- Teams are determined from combatant
Teamvalues, sorted ascending. Team 0 goes first. - Each team phase starts with full points = number of alive members (capped by
maxPointsPerTeamif set). - You choose who acts via
SelectActor(), then callGetNextActor()to retrieve them. - Actions consume or convert points. When points are exhausted, the next team's phase begins.
- A KO mid-phase does not retroactively shrink points — dead actors simply cannot be selected.
Properties
| Property | Type | Description |
|---|---|---|
ActiveTeam | int | The team ID currently taking their phase. |
FullPoints | int | Remaining full turn points. |
HalfPoints | int | Remaining half turn points. |
TotalPoints | int | Full + half points combined. |
HasActionsRemaining | bool | true if TotalPoints > 0. |
Additional Methods
| Method | Description |
|---|---|
SelectActor(ICombatant) | Choose who acts next. Must be alive and on the active team. Throws if not. |
ConsumeAction() | Spends one point. Consumes a half point first; if none, consumes a full point. |
ConvertAction() | Converts one full point into a half point. If no full points, consumes a half point instead. |
ConsumeActions(int count) | Consumes multiple points (half first, then full). |
ConsumeAllActions() | Ends the team's phase immediately by zeroing all points. |
Usage
var press = new PressTurnSystem();
battle.Start(combatants, press);
// Team 0's phase — 1 point per alive member
press.SelectActor(myChosenActor);
var actor = battle.BeginNextTurn();
// ... do action ...
press.ConsumeAction();
battle.EndTurn();
// If points remain, select another actor
if (press.HasActionsRemaining)
{
press.SelectActor(anotherActor);
var next = battle.BeginNextTurn();
// ... hit a weakness? Convert instead of consume ...
press.ConvertAction();
battle.EndTurn();
}
ActionPointTurnSystem
Action Point system. Combatants bank or spend Action Points for extra actions.
Constructor
new ActionPointTurnSystem(int defaultAP = 0, int minAP = -4, int maxAP = 4)
defaultAP— starting AP for each combatant. Default:0.minAP— lowest AP can go (going negative means skipping future turns). Default:-4.maxAP— highest AP can be banked. Default:4.
Behavior
- Uses speed-sorted round-robin like
ClassicTurnSystemfor base turn order. - At the start of each round, all alive combatants gain
+1 APpassively (capped atmaxAP). SpendAP()spends 1 AP to queue an extra action this turn. Can be called multiple times. AP can go negative down tominAP.SaveAP()gains 1 AP (capped atmaxAP) and the combatant skips their action.- Combatants with negative AP are skipped when their turn comes — they must wait for passive AP to bring them back to 0.
- If a combatant dies mid-spend-chain, remaining extra actions are cleared.
Properties
| Property | Type | Description |
|---|---|---|
MinAP | int | The minimum AP value (constructor param). |
MaxAP | int | The maximum AP value (constructor param). |
Additional Methods
| Method | Description |
|---|---|
GetAP(ICombatant) | Returns the combatant's current Action Points. |
SpendAP() | Spend 1 AP for an extra action this turn. Throws if already at minAP. |
SaveAP() | Gain 1 AP (capped at maxAP). The combatant should skip their action. |
Usage
var ap = new ActionPointTurnSystem(0, -4, 4);
battle.Start(combatants, ap);
var actor = battle.BeginNextTurn();
// Option A: SpendAP — take multiple actions now, risk negative AP
ap.SpendAP(); // AP: 0 -> -1, queues 1 extra action
// ... do first action ...
battle.EndTurn();
// Extra action fires — same actor again
var same = battle.BeginNextTurn();
// ... do second action ...
battle.EndTurn();
// Option B: SaveAP — skip turn, bank AP for later
var next = battle.BeginNextTurn();
ap.SaveAP(); // AP +1
battle.EndTurn(); // Skips action
Comparison Table
| Feature | Classic | ATB | Timeline | Press Turn | Action Point |
|---|---|---|---|---|---|
| Turn order | Speed sort | Gauge fill | Tick timeline | Team phase | Round-robin |
| Real-time | No | Yes (Tick) | No | No | No |
| Player choice in order | No | No | Via action cost | SelectActor | Spend/Save AP |
| Team-based | No | No | No | Yes | No |
| Resource management | None | None | Action cost | Turn points | AP |
Cookbook
Copy-paste recipes for common battle patterns. Your formulas, your rules — these are starting points.
Mana Costs
SimpleJRPG doesn't manage MP — that's your domain. Check costs before calling combat verbs, deduct MP yourself.
void CastFireball(ICombatant caster, ICombatant target)
{
int mpCost = 8;
var myCaster = (MyCombatant)caster;
if (myCaster.MP < mpCost) { Debug.Log("Not enough MP!"); return; }
myCaster.MP -= mpCost;
int damage = myCaster.MagicPower * 3;
battle.DealDamage(caster, target, damage, "magical", "fire");
}
Elemental Weakness / Resistance
Use OnBeforeDamage to modify damage based on element.
battle.OnBeforeDamage += evt =>
{
var target = (MyCombatant)evt.Target;
float multiplier = target.GetElementMultiplier(evt.Element);
evt.Amount = (int)(evt.Amount * multiplier);
if (multiplier == 0f) evt.Cancel = true; // Immune
};
Status Effect Timers
Track durations yourself and call RemoveStatus() when they expire. Tick in OnTurnEnd.
Dictionary<ICombatant, List<(string id, int turnsLeft)>> statuses = new();
battle.OnStatusApplied += evt =>
{
if (!statuses.ContainsKey(evt.Target)) statuses[evt.Target] = new();
statuses[evt.Target].Add((evt.StatusId, evt.Duration));
};
battle.OnTurnEnd += evt =>
{
if (!statuses.ContainsKey(evt.Actor)) return;
var list = statuses[evt.Actor];
for (int i = list.Count - 1; i >= 0; i--)
{
if (list[i].id == "poison")
battle.DealDamage(evt.Actor, evt.Actor, 5, "status", "poison");
var updated = (list[i].id, list[i].turnsLeft - 1);
if (updated.Item2 <= 0) { battle.RemoveStatus(evt.Actor, list[i].id); list.RemoveAt(i); }
else list[i] = updated;
}
};
Buff Stacking
Track buff multipliers yourself. Apply them in OnBeforeDamage / OnBeforeHeal.
Dictionary<ICombatant, Dictionary<string, (float mult, int turns)>> buffs = new();
battle.OnBuffApplied += evt =>
{
if (!buffs.ContainsKey(evt.Target)) buffs[evt.Target] = new();
buffs[evt.Target][evt.BuffId] = (evt.Amount, evt.Duration); // Replace
};
battle.OnBeforeDamage += evt =>
{
if (buffs.TryGetValue(evt.Source, out var dict))
if (dict.TryGetValue("attack_up", out var buff))
evt.Amount = (int)(evt.Amount * buff.mult);
};
Revive Logic
void CastRevive(ICombatant caster, ICombatant target, bool full)
{
if (target.IsAlive) return;
var myTarget = (MyCombatant)target;
int hp = full ? myTarget.MaxHP : myTarget.MaxHP / 2;
battle.Revive(target, hp);
}
Flee Chance
void AttemptFlee(ICombatant actor)
{
float partySpeed = battle.GetAlive(0).Average(c => c.Speed);
float enemySpeed = battle.GetAlive(1).Average(c => c.Speed);
float chance = Mathf.Clamp01(partySpeed / (partySpeed + enemySpeed));
if (UnityEngine.Random.value < chance) battle.Flee(actor);
else { Debug.Log("Couldn't escape!"); battle.EndTurn(); }
}
Multi-Hit Attacks
void Barrage(ICombatant actor, ICombatant target, int hitCount)
{
for (int i = 0; i < hitCount; i++)
{
int damage = CalculateDamage(actor, target);
bool isCrit = UnityEngine.Random.value < 0.15f;
battle.DealDamage(actor, target, damage, "physical", "", isCrit);
if (!target.IsAlive) break;
}
}
Damage Shields / Barriers
Use OnBeforeDamage to absorb or cancel damage. Track shield HP yourself.
Dictionary<ICombatant, int> shields = new();
battle.OnBeforeDamage += evt =>
{
if (!shields.TryGetValue(evt.Target, out int hp) || hp <= 0) return;
if (hp >= evt.Amount) { shields[evt.Target] = hp - evt.Amount; evt.Cancel = true; }
else { evt.Amount -= hp; shields[evt.Target] = 0; battle.RemoveBuff(evt.Target, "shield"); }
};
Counter Attacks
battle.OnDamageDealt += evt =>
{
if (!evt.Target.IsAlive) return;
var target = (MyCombatant)evt.Target;
if (!target.HasSkill("counter")) return;
if (UnityEngine.Random.value < 0.3f)
battle.DealDamage(evt.Target, evt.Source, CalculateDamage(evt.Target, evt.Source), "physical", "");
};
Critical Hits
void Attack(ICombatant attacker, ICombatant target)
{
int baseDamage = CalculateDamage(attacker, target);
bool isCrit = UnityEngine.Random.value < 0.1f;
int finalDamage = isCrit ? baseDamage * 2 : baseDamage;
battle.DealDamage(attacker, target, finalDamage, "physical", "", isCrit);
}
battle.OnDamageDealt += evt =>
{
if (evt.WasCrit) ShowBigDamageNumber(evt.Amount);
else ShowDamageNumber(evt.Amount);
};
Drain / Life Steal
void DrainAttack(ICombatant attacker, ICombatant target)
{
int damage = CalculateDamage(attacker, target);
battle.DealDamage(attacker, target, damage, "magical", "dark");
battle.Heal(attacker, attacker, damage / 2);
}
Instant Kill
void CastDeath(ICombatant caster, ICombatant target)
{
var myTarget = (MyCombatant)target;
if (myTarget.IsImmuneToInstantDeath) { Debug.Log($"{target.Name} resisted!"); return; }
if (UnityEngine.Random.value < 0.3f) battle.Kill(target, caster);
else Debug.Log("It missed!");
}
Damage Formula Examples
// Simple: attack - defense
int SimpleDamage(MyCombatant atk, MyCombatant def)
=> Math.Max(1, atk.Attack - def.Defense);
// DQ-style: attack/2 - defense/4, randomized
int DQDamage(MyCombatant atk, MyCombatant def)
{
int base_ = Math.Max(1, atk.Attack / 2 - def.Defense / 4);
return (int)(base_ * UnityEngine.Random.Range(0.875f, 1.125f));
}
// Level-scaled with crit
int ScaledDamage(MyCombatant atk, MyCombatant def, bool isCrit)
{
float levelBonus = 1f + (atk.Level - def.Level) * 0.05f;
int raw = Math.Max(1, (int)(atk.Attack * levelBonus) - def.Defense);
return isCrit ? raw * 2 : raw;
}
API Reference
Complete reference for every type, member, method, event, and struct in SimpleJRPG.
ICombatant Interface
The interface users implement for their characters and enemies. Battle never modifies combatants directly — it calls TakeDamage() and Heal(), and your implementation decides what that means.
| Member | Type | Description |
|---|---|---|
Name |
string (get) |
Display name |
IsAlive |
bool (get) |
Whether combatant is alive |
Team |
int (get) |
Team identifier (0 = player) |
Speed |
float (get) |
Speed stat for turn order |
TakeDamage(int amount) |
void |
Reduce HP by amount |
Heal(int amount) |
void |
Restore HP by amount |
ITurnSystem Interface
The interface for turn order logic. Implement this to create custom turn systems, or use one of the built-in implementations.
| Member | Type | Description |
|---|---|---|
Init(List<ICombatant> combatants) |
void |
Initialize with combatant list |
GetNextActor() |
ICombatant |
Get next combatant to act |
GetTimeline() |
IReadOnlyList<ICombatant> |
Predicted turn order |
OnActorDied(ICombatant) |
void |
Notify of combatant death |
OnActorAdded(ICombatant) |
void |
Notify of combatant addition |
OnTurnEnd() |
void |
Notify turn completed |
Battle — Properties
| Property | Type | Description |
|---|---|---|
State |
BattleState |
Current battle state |
TurnNumber |
int |
Current turn count |
Combatants |
List<ICombatant> |
All combatants |
TurnSystem |
ITurnSystem |
Active turn system |
CurrentActor |
ICombatant |
Current acting combatant |
Result |
BattleResult? |
Result after battle ends |
History |
IReadOnlyList<BattleHistoryEntry> |
All recorded events |
Battle — Methods
| Method | Returns | Description |
|---|---|---|
Start(List<ICombatant>, ITurnSystem) |
void |
Start battle |
EndBattle(BattleState) |
void |
End battle with result |
BeginNextTurn() |
ICombatant |
Start next turn, returns actor |
EndTurn() |
void |
End current turn |
DealDamage(ICombatant source, ICombatant target, int amount, string damageType = "", string element = "", bool isCrit = false) |
void |
Deal damage to single target |
DealDamage(ICombatant source, IList<ICombatant> targets, int amount, string damageType = "", string element = "", bool isCrit = false) |
void |
Deal damage to multiple targets |
Heal(ICombatant source, ICombatant target, int amount) |
void |
Heal single target |
Heal(ICombatant source, IList<ICombatant> targets, int amount) |
void |
Heal multiple targets |
Kill(ICombatant target, ICombatant killer = null) |
void |
Force KO |
Revive(ICombatant target, int hpAmount) |
void |
Revive dead target |
ApplyStatus(ICombatant source, ICombatant target, string statusId, int duration = -1) |
void |
Apply status effect |
ApplyStatus(ICombatant source, IList<ICombatant> targets, string statusId, int duration = -1) |
void |
Apply status to multiple |
RemoveStatus(ICombatant target, string statusId) |
void |
Remove status |
ApplyBuff(ICombatant source, ICombatant target, string buffId, float amount, int duration = -1) |
void |
Apply buff |
ApplyBuff(ICombatant source, IList<ICombatant> targets, string buffId, float amount, int duration = -1) |
void |
Apply buff to multiple |
RemoveBuff(ICombatant target, string buffId) |
void |
Remove buff |
Flee(ICombatant who) |
void |
Flee from battle |
RemoveCombatant(ICombatant) |
void |
Remove from battle entirely |
CheckBattleEnd() |
bool |
Check if battle should end |
GetAlive(int team = -1) |
List<ICombatant> |
Get alive combatants |
GetDead(int team = -1) |
List<ICombatant> |
Get dead combatants |
GetEnemies(ICombatant) |
List<ICombatant> |
Get alive enemies |
GetAllies(ICombatant, bool includeSelf = false) |
List<ICombatant> |
Get alive allies |
GetTimeline() |
IReadOnlyList<ICombatant> |
Get turn order from turn system |
Battle — Events
| Event | Signature | When |
|---|---|---|
OnBattleStart |
Action<Battle> |
Battle.Start() called |
OnBattleEnd |
Action<Battle, BattleState> |
Battle ends |
OnTurnStart |
Action<TurnEvent> |
BeginNextTurn() called |
OnTurnEnd |
Action<TurnEvent> |
EndTurn() called |
OnBeforeDamage |
Action<BeforeDamageEvent> |
Before damage applied (cancellable) |
OnDamageDealt |
Action<DamageEvent> |
After damage applied |
OnBeforeHeal |
Action<BeforeHealEvent> |
Before heal applied (cancellable) |
OnHealed |
Action<HealEvent> |
After heal applied |
OnKO |
Action<KOEvent> |
Combatant knocked out |
OnRevived |
Action<ICombatant> |
Combatant revived |
OnStatusApplied |
Action<StatusEvent> |
Status applied |
OnStatusRemoved |
Action<ICombatant, string> |
Status removed |
OnBuffApplied |
Action<BuffEvent> |
Buff applied |
OnBuffRemoved |
Action<ICombatant, string> |
Buff removed |
OnFled |
Action<ICombatant> |
Combatant fled |
OnCombatantRemoved |
Action<ICombatant> |
Combatant removed |
OnGroupDamageDealt |
Action<GroupDamageEvent> |
Multi-target damage |
OnGroupHealed |
Action<GroupHealEvent> |
Multi-target heal |
OnGroupStatusApplied |
Action<GroupStatusEvent> |
Multi-target status |
OnGroupBuffApplied |
Action<GroupBuffEvent> |
Multi-target buff |
BattleEvents — Structs
DamageEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who dealt the damage |
Target |
ICombatant |
Who received the damage |
Amount |
int |
Damage amount |
DamageType |
string |
Pass-through damage type |
Element |
string |
Pass-through element |
WasCrit |
bool |
Whether the hit was critical |
HealEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who performed the heal |
Target |
ICombatant |
Who was healed |
Amount |
int |
Heal amount |
StatusEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who applied the status |
Target |
ICombatant |
Who received the status |
StatusId |
string |
Pass-through status identifier |
Duration |
int |
Duration in turns (-1 = permanent) |
BuffEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who applied the buff |
Target |
ICombatant |
Who received the buff |
BuffId |
string |
Pass-through buff identifier |
Amount |
float |
Numeric value (user-interpreted) |
Duration |
int |
Duration in turns (-1 = permanent) |
KOEvent
| Field | Type | Description |
|---|---|---|
Target |
ICombatant |
Who was knocked out |
Killer |
ICombatant (nullable) |
Who caused the KO (null if none) |
TurnEvent
| Field | Type | Description |
|---|---|---|
Actor |
ICombatant |
Whose turn it is |
TurnNumber |
int |
Current turn number |
GroupDamageEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who dealt the damage |
Hits |
IReadOnlyList<DamageEvent> |
Individual damage events |
TotalDamage |
int |
Sum of all damage dealt |
GroupHealEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who performed the heals |
Heals |
IReadOnlyList<HealEvent> |
Individual heal events |
TotalHealed |
int |
Sum of all HP restored |
GroupStatusEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who applied the statuses |
Applications |
IReadOnlyList<StatusEvent> |
Individual status events |
GroupBuffEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who applied the buffs |
Applications |
IReadOnlyList<BuffEvent> |
Individual buff events |
BattleHistoryEntry
| Field | Type | Description |
|---|---|---|
Sequence |
int |
Global sequence number |
TurnNumber |
int |
Turn when event occurred |
EventType |
string |
Type of event recorded |
Event |
object |
The event data (cast to specific type) |
BattleResult
| Field | Type | Description |
|---|---|---|
Outcome |
BattleState |
Victory, Defeat, or Fled |
TotalTurns |
int |
Total turns taken |
Survivors |
List<ICombatant> |
Combatants still alive at end |
History |
IReadOnlyList<BattleHistoryEntry> |
Complete battle event log |
BattleEvents — Before-Events (Classes)
Before-events are mutable classes (not structs) that allow subscribers to modify or cancel actions before they execute.
BeforeDamageEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who is dealing the damage |
Target |
ICombatant |
Who will receive the damage |
Amount |
int (settable) |
Damage amount — modify to change final damage |
DamageType |
string |
Pass-through damage type |
Element |
string |
Pass-through element |
IsCrit |
bool |
Whether the hit is critical |
Cancel |
bool (settable) |
Set to true to prevent damage entirely |
BeforeHealEvent
| Field | Type | Description |
|---|---|---|
Source |
ICombatant |
Who is performing the heal |
Target |
ICombatant |
Who will be healed |
Amount |
int (settable) |
Heal amount — modify to change final heal |
Cancel |
bool (settable) |
Set to true to prevent heal entirely |
BattleState Enum
| Value | Description |
|---|---|
NotStarted |
Battle has not begun yet |
WaitingForCommands |
Waiting for the next turn to begin |
Executing |
A turn is in progress |
Victory |
Player team won |
Defeat |
Player team lost |
Fled |
A combatant fled the battle |
Demos
6 interactive demo scenes that showcase every feature of SimpleJRPG.
Overview
SimpleJRPG ships with 6 interactive demo scenes, each generated via a menu item in the Unity editor. Every scene is fully self-contained with its own UI and demonstrates a specific feature of the API. No setup required — just generate and hit Play.
Generating Demos
Open the Unity menu bar and go to Window > Simple JRPG. Each menu item generates a fully-wired scene with combatants, UI, and event subscriptions already configured.
Battle Core Demo
Menu: Window > Simple JRPG > Generate Battle Core Demo
The kitchen-sink demo. Demonstrates ALL core features of the Battle class: full lifecycle (StartBattle through EndBattle), DealDamage (single-target and multi-target), Heal, Kill, Revive, before-events (toggle cancel and toggle halve), ApplyStatus, ApplyBuff, Flee, battle history, RemoveCombatant, BattleResult, and the GetEnemies/GetAllies query helpers.
UI layout: Party HP panel on the left, action buttons in the center, and a scrolling event log on the right.
Classic Turn Demo
Menu: Window > Simple JRPG > Generate Classic Turn Demo
A 3v3 party battle using ClassicTurnSystem. Step through turns one at a time,
see the turn order displayed, watch auto-attacks resolve, observe round cycling as all
combatants take their turns, and see dead combatants get skipped. Includes revive to
demonstrate mid-battle turn order updates.
ATB Turn Demo
Menu: Window > Simple JRPG > Generate ATB Turn Demo
A 3v3 party battle using ATBTurnSystem. A Tick button advances time, filling
each combatant's gauge based on their speed. Gauge bars are displayed per combatant.
When a combatant's gauge is full, they become ready and you can select an action for them.
Timeline Turn Demo
Menu: Window > Simple JRPG > Generate Timeline Turn Demo
A 3v3 party battle using TimelineTurnSystem. Displays the full timeline showing
when each combatant will act next. Actions have different cast-time costs —
Quick (50), Normal (100), and Heavy (200) — that push the actor further down the
timeline. Tick progression advances the timeline and resolves turns as they come up.
Press Turn Demo
Menu: Window > Simple JRPG > Generate Press Turn Demo
A two-team battle using PressTurnSystem. Displays the current phase and the
team's turn point pool as Full and Half points. Demonstrates SelectActor to pick who acts,
ConsumeAction for normal actions, ConvertAction to convert a Full point to Half,
and ConsumeAllActions to end the phase early.
Action Point Turn Demo
Menu: Window > Simple JRPG > Generate Action Point Turn Demo
A 3v3 party battle using ActionPointTurnSystem. Displays each combatant's
current AP (Action Points). Spend AP queues extra actions in a single turn.
Save AP banks points by skipping a turn. Demonstrates the risk/reward of going negative
on AP for burst damage versus saving up for future turns.
About
The design philosophy behind SimpleJRPG.
Design Philosophy
SimpleJRPG orchestrates battles — turn order, events, state management. You implement damage formulas, design status effects, and build your UI.
SimpleJRPG has no opinions on your game design. It uses pass-through strings instead of enums for all game-specific values like damage types, elements, status IDs, and buff IDs. We never interpret what "Fire" or "Poison" means — your system decides.
Everything is instance-based. No singletons, no statics, no global state. Create a
Battle instance, pass in your combatants and a turn system, subscribe to
events, and call combat verbs. That's it.
What SimpleJRPG Is NOT
- Not a complete battle system — it's the skeleton, you add the flesh. We provide orchestration; you provide the game.
- Not opinionated about damage formulas, stat systems, or game balance — you do the math, pass us the result, and we route it through.
- Not a UI framework — events let you drive any UI you want. We fire
OnDamageDealt, you decide what that looks like on screen. - Not dependent on Unity — pure C#, no MonoBehaviours, no ScriptableObjects, no UnityEngine imports. The asmdef uses
noEngineReferences: true.
Version History
-
v2.0 (February 2026) — Complete rewrite. Lean runtime API with an instance-based
Battleclass, pluggable turn systems (Classic, ATB, Timeline, Press Turn, Action Point), before-events for cancellation and modification, and battle helpers (multi-target, queries, history, results). - v1.0 (scrapped) — ScriptableObject-based composable effects system. Over-engineered abstraction layers. Lesson learned.
Support
For questions, bug reports, and feature requests:
- Email — livingfailuregames@gmail.com
- Website — livingfailure.com
- Unity Asset Store — leave a review or use the Q&A section on the SimpleJRPG store page.