---
name: set-basic-lightweight-combat
description: Experimental, optional combat fallback of the Set World API for games without combat rules. Use when a game needs one attack resolved or one initiative window scheduled from reconciled Set stats, and for the full schemas of both responses. It has no status effects, no equipment effects and no saved combat state, and games with their own combat can ignore it.
license: Free to use when obtained from set.world, commercially too (the API, what it returns, the skills and downloaded models). The source code of Set is © Jimmy Lee under CC BY-NC 4.0 and its commercial use needs a paid waiver.
metadata:
  version: "0.8.9"
  title: Basic lightweight combat
---

# Basic lightweight combat

**Experimental and optional.** Use it if your game has no combat rules yet. Games built on player timing, aiming or collision can ignore it. It is a small reading of Set's attributes, not a balance guarantee.

Each request resolves one attack or schedules action opportunities for one time window. Set supplies the arithmetic. Your game keeps health, stamina, positions, cooldowns, targets and history, and decides when to call. Nothing is saved and no rewards are given.

**Start:** read `GET /api/basic-lightweight-combat/spec`, roll fighters with `GET /api/roll/character`, and send their `finalStats`. Keep `rulesVersion`, `contentVersion` and any `seed` with each result.

| Read or calculate | Endpoint |
| --- | --- |
| This skill | `GET /skills/basic-lightweight-combat` |
| Formulas, limits and current versions | `GET /api/basic-lightweight-combat/spec` |
| One attack | `GET /api/basic-lightweight-combat?mode=damage&…` |
| Who acts first and how often | `GET /api/basic-lightweight-combat?mode=initiative&…` |

## Supply reconciled stats

Send `finalStats` from a character response, or your own reconciled block. Do not apply traits or equipment bonuses a second time. The order (from `/api/stats`) is:

```text
strength, dexterity, intelligence, wisdom, agility, vitality, perception, resolve, luck
```

All nine must be integers in 8–24. The five primaries feed the attribute formulas; wisdom, perception, resolve and luck are accepted but unused. Level does not scale damage or grant moves.

## Resolve an attack

```sh
curl 'https://set.world/api/basic-lightweight-combat?mode=damage&attacker=16-16-16-16-16-16-16-16-16&defender=16-16-16-16-16-16-16-16-16&health=100&stamina=40&seed=42'
```

| Input | Meaning |
| --- | --- |
| `attacker`, `defender` | Required, nine dash-joined stats each |
| `kind` | `physical` (default), `magical` or `action` |
| `health` | Defender's current health; defaults to its maximum |
| `stamina` | Attacker's stamina before recovery; defaults to its maximum |
| `elapsedMs` | Attacker recovery time, integer 0–30,000; default 0 |
| `seed` | Optional uint32; omit for an unseeded draw |
| `rulesVersion`, `contentVersion` | Optional exact-version preconditions |

Health and stamina may be fractional, from zero to their derived maxima. Out-of-range values fail rather than being clamped.

### Damage

| Kind | Base damage | Defender reduction | Real-time cadence |
| --- | --- | --- | --- |
| physical | `physicalDamage` | `physicalDamageReduction` | `1000 / attackSpeed` ms |
| magical | `magicalDamage` | `magicalDamageReduction` | `1000 / castSpeed` ms |
| action | `actionDamage` | `physicalDamageReduction` | `1000 / chargeSpeedAction` ms |

```text
dodgeChance = clamp(0, 1, defender.dodgeRate * (1 - clamp(0, 1, attacker.clarity)))
criticalChance = clamp(0, 1, attacker.criticalHitChance)
rolledDamage = max(1, round(baseDamage * (1 - reduction) * (1 - globalResistance)
                           * (critical ? 2 : 1)))
damage = min(defenderHealth, rolledDamage)
```

Dodge is drawn first; a dodge deals zero and skips the critical draw. Otherwise the critical draw doubles damage before rounding. With every stat at 16, a physical hit deals 21, or 41 on a critical.

### Stamina and results

Recovery is `10 * elapsedMs / 1000`, capped at the maximum. Every attempt costs 20, including a miss. Below 20 there is no attempt and no draw. A defender already at zero health also blocks the attempt at no cost.

The response includes:

- `experimental`, `rulesVersion`, `contentVersion`, `mode`, `seed` and effective `inputs`.
- Both stat blocks and their derived attributes.
- `outcome`: `hit`, `miss`, `insufficient-stamina` or `defender-defeated`.
- `attempted`, `hit`, `critical` and actual `damage`.
- `health`: before, after, maximum.
- `stamina`: before, recovered, ready, spent, after, maximum.
- `roll`: raw damage, probabilities, mitigation and draws; null when there was no attempt.
- `timing`: attack, movement and initiative intervals in milliseconds.

```ts
type CombatSide = { stats: StatBlock; attributes: Record<string, number> };   // the 23 derived attributes; StatBlock: the Stats skill
type Timing = { movementIntervalMs: number; attackIntervalMs: number; initiativeIntervalMs: number };

type DamageResponse = {
  experimental: true;
  rulesVersion: string;            // "basic-lightweight-combat-1"
  mode: 'damage';
  seed: number | null;
  inputs: { attacker: number[]; defender: number[]; kind: 'physical' | 'magical' | 'action'; health: number; stamina: number; elapsedMs: number };
  attacker: CombatSide;
  defender: CombatSide;
  outcome: 'hit' | 'miss' | 'insufficient-stamina' | 'defender-defeated';
  attempted: boolean;
  hit: boolean;
  critical: boolean;
  damage: number;                  // health actually removed
  health: { before: number; after: number; maximum: number };
  stamina: { before: number; recovered: number; ready: number; spent: number; after: number; maximum: number };
  roll: null | {
    hit: boolean; critical: boolean; rolledDamage: number; baseDamage: number; reduction: number; globalResistance: number;
    dodgeChance: number; criticalChance: number; rolls: { dodge: number; critical: number | null };
  };
  timing: Timing;
  contentVersion: string;
};
```

The request above, answered at this `contentVersion` (this seed's outcome is `hit`):

```jsonc
{
  "experimental": true,
  "rulesVersion": "basic-lightweight-combat-1",
  "mode": "damage",
  "seed": 42,
  "inputs": { "attacker": [16, 16, 16, 16, 16, 16, 16, 16, 16], "defender": [16, 16, 16, 16, 16, 16, 16, 16, 16], "kind": "physical", "health": 100, "stamina": 40, "elapsedMs": 0 },
  "attacker": { "stats": { "strength": 16, "dexterity": 16, "intelligence": 16, "wisdom": 16, "agility": 16, "vitality": 16, "perception": 16, "resolve": 16, "luck": 16 }, "attributes": { /* the 23 derived attributes of these stats */ } },
  "defender": { "stats": { "strength": 16, "dexterity": 16, "intelligence": 16, "wisdom": 16, "agility": 16, "vitality": 16, "perception": 16, "resolve": 16, "luck": 16 }, "attributes": { /* the 23 derived attributes of these stats */ } },
  "outcome": "hit",
  "attempted": true,
  "hit": true,
  "critical": false,
  "damage": 21,
  "health": { "before": 100, "after": 79, "maximum": 168 },
  "stamina": { "before": 40, "recovered": 0, "ready": 40, "spent": 20, "after": 20, "maximum": 88 },
  "roll": {
    "hit": true,
    "critical": false,
    "rolledDamage": 21,
    "baseDamage": 26,
    "reduction": 0.128,
    "globalResistance": 0.088,
    "dodgeChance": 0.12096000000000001,
    "criticalChance": 0.116,
    "rolls": { "dodge": 0.6011037519201636, "critical": 0.44829055899754167 }
  },
  "timing": { "movementIntervalMs": 344.82758620689657, "attackIntervalMs": 862.0689655172414, "initiativeIntervalMs": 874.1258741258742 },
  "contentVersion": "4.swpi78HUCtcrZ1Eoi9dipw"
}
```

Apply the changes once, and pass the resulting stamina into the next request. Count each actor's recovery time once. Only call for a living attacker that your range and cooldown rules allow; the endpoint cannot know those facts.

## Resolve initiative

A **move is an action opportunity**, not a grid step or a guaranteed attack. Every clock starts at time zero:

```text
actionIntervalMs = 1000 / initiative
action n occurs at n * actionIntervalMs, starting at n = 1
```

```sh
curl --get 'https://set.world/api/basic-lightweight-combat' \
  --data-urlencode 'mode=initiative' \
  --data-urlencode 'actors=[{"id":"steady","stats":[8,8,8,8,8,8,8,8,8]},{"id":"quick","stats":[24,24,24,24,24,24,24,24,24]}]' \
  --data-urlencode 'startMs=0' \
  --data-urlencode 'durationMs=5000'
```

`steady` has initiative 1, so it acts every 1000 ms: 5 moves in the window. `quick` has initiative 1.288, so it acts every 776.3975 ms: 6 moves in the window. `quick` acts first, and the window holds 11 opportunities. Exact ties keep input order.

| Input | Domain |
| --- | --- |
| `actors` | JSON array of 1–128 `{ id, stats }`; unique, nonblank string IDs up to 64 characters |
| `startMs` | Nonnegative integer, default 0 |
| `durationMs` | Integer 1–30,000, default 5,000 |
| Time bound | `startMs + durationMs <= 1,000,000,000` |
| Query length | `actors` up to 65,536 characters; transport limits may be lower |

The result returns `first`, chronological `order`, and `actors` with `moves`, initiative, first/next opportunity times and timing values. Each event has actor ID, input index, action ordinal and `atMs`.

```ts
type InitiativeResponse = {
  experimental: true;
  rulesVersion: string;            // "basic-lightweight-combat-1"
  mode: 'initiative';
  inputs: { actors: Array<{ id: string; stats: number[] }>; startMs: number; durationMs: number };
  window: { startMs: number; endMs: number; durationMs: number; boundary: '(startMs, endMs]' };
  moveMeaning: string;
  tieBreak: 'input order';
  first: InitiativeEvent | null;
  actors: Array<{ id: string; stats: number[]; initiative: number; moves: number; firstAtMs: number | null; nextAtMs: number; timing: Timing }>;
  order: InitiativeEvent[];        // chronological
  contentVersion: string;
};
type InitiativeEvent = { actorId: string; index: number; ordinal: number; atMs: number };   // index: position in the input; ordinal: that actor's nth action
```

The request above, answered in full:

```json
{
  "experimental": true,
  "rulesVersion": "basic-lightweight-combat-1",
  "mode": "initiative",
  "inputs": { "actors": [{ "id": "steady", "stats": [8, 8, 8, 8, 8, 8, 8, 8, 8] }, { "id": "quick", "stats": [24, 24, 24, 24, 24, 24, 24, 24, 24] }], "startMs": 0, "durationMs": 5000 },
  "window": { "startMs": 0, "endMs": 5000, "durationMs": 5000, "boundary": "(startMs, endMs]" },
  "moveMeaning": "action opportunity, not grid distance or guaranteed attack",
  "tieBreak": "input order",
  "first": { "actorId": "quick", "index": 1, "ordinal": 1, "atMs": 776.3975 },
  "actors": [
    {
      "id": "steady",
      "stats": [8, 8, 8, 8, 8, 8, 8, 8, 8],
      "initiative": 1,
      "moves": 5,
      "firstAtMs": 1000,
      "nextAtMs": 6000,
      "timing": { "movementIntervalMs": 400, "attackIntervalMs": 1000, "initiativeIntervalMs": 1000 }
    },
    {
      "id": "quick",
      "stats": [24, 24, 24, 24, 24, 24, 24, 24, 24],
      "initiative": 1.288,
      "moves": 6,
      "firstAtMs": 776.3975,
      "nextAtMs": 5434.7826,
      "timing": { "movementIntervalMs": 303.030303030303, "attackIntervalMs": 757.5757575757575, "initiativeIntervalMs": 776.3975155279503 }
    }
  ],
  "order": [
    { "actorId": "quick", "index": 1, "ordinal": 1, "atMs": 776.3975 },
    { "actorId": "steady", "index": 0, "ordinal": 1, "atMs": 1000 },
    { "actorId": "quick", "index": 1, "ordinal": 2, "atMs": 1552.795 },
    { "actorId": "steady", "index": 0, "ordinal": 2, "atMs": 2000 },
    { "actorId": "quick", "index": 1, "ordinal": 3, "atMs": 2329.1925 },
    { "actorId": "steady", "index": 0, "ordinal": 3, "atMs": 3000 },
    { "actorId": "quick", "index": 1, "ordinal": 4, "atMs": 3105.5901 },
    { "actorId": "quick", "index": 1, "ordinal": 5, "atMs": 3881.9876 },
    { "actorId": "steady", "index": 0, "ordinal": 4, "atMs": 4000 },
    { "actorId": "quick", "index": 1, "ordinal": 6, "atMs": 4658.3851 },
    { "actorId": "steady", "index": 0, "ordinal": 5, "atMs": 5000 }
  ],
  "contentVersion": "4.swpi78HUCtcrZ1Eoi9dipw"
}
```

Windows are **open at the start and closed at the end**, `(startMs, endMs]`. Start the next window at the previous `window.endMs`, not a rounded event time; an event on the boundary belongs to the earlier window. Adjacent windows produce the same schedule as one long window. Changing an actor's stats restarts its clock from the same epoch; carrying progress across a change is your game's job. Scheduling uses integer rate units, `round(initiative * 10000)`, and timestamps are rounded to four decimals for display. This mode uses no randomness and rejects `seed`.

Use each opportunity to choose an action. Remove defeated actors yourself. Stamina and targeting may still prevent an attack; the schedule spends nothing.

## Using the timing

For a turn game, treat initiative `moves` as turns and resolve an eligible attack on the ones you choose. For a real-time game, use the attack cadence, and `movementIntervalMs = 400 / walkSpeed` if useful, with your own gating. These are alternatives: never add initiative, attack and movement rates together into extra actions.

## Set's own demos

The home page's dungeon and army and the [Anima scene](https://set.world/anima-scene) call this profile's helpers locally, not over HTTP, inside their own rules. Those rules, and how they differ from this endpoint, are in [Derived combat](https://set.world/skills/anima-derived-combat). Their scaled stats can exceed 24, so do not send them here.

## What stays yours

Status effects and weapon or equipment effects are excluded. There is no budget allocation, trait reapplication, range check, pathfinding, target choice, healing, reward or progression. Stamina recovery is not health recovery. `ailmentResistance`, `chargeSpeedCast`, `carrySpeed`, `carryCapacity`, `jumpHeight`, `visionRange` and `reflexes` have no role here.

## Replay and failures

Keep the mode, effective inputs, damage seed, `rulesVersion` and `contentVersion`. Use `deriveSeed(base, attackIndex)` for a series of seeded attacks; reusing a seed repeats its draws. A new `elapsedMs` or resource value is a different request.

Malformed, unsupported or mode-incompatible parameters return HTTP 400 with `{ error: { code: "INVALID_COMBAT_INPUT", message, field } }`. Repeated parameters use their first value. An empty `seed=` is unseeded; other empty numbers and empty version strings are invalid. Omitted resources default to full; an explicit zero stays zero. A version precondition that differs from the server returns 409 (`VERSION_MISMATCH`). Methods other than GET return 405 (`METHOD_NOT_ALLOWED`); OPTIONS is handled by CORS.

The rules are `basic-lightweight-combat-1`. Any change advances the rules version under Set's content revision policy. Old rules are not hosted, so keep complete results if you need them.
