Skip to content

Modding Guide

TinyGenerals is data-driven — game rules, factions, and visuals are defined in JSON files and sprite sheets. This guide covers the format and structure you need to create custom content.


A ruleset is a single JSON file that defines all game rules: terrain, units, buildings, factions, combat, and vision. The default ruleset is “Classic TinyGenerals.”

{
"id": "my-ruleset",
"name": "My Custom Ruleset",
"faction_mode": "optional",
"faction_selection": {
"required": false,
"default_behavior": "prompt",
"allow_mirror_matches": true,
"auto_balance": false
},
"factions": { ... },
"terrain_types": { ... },
"overlay_types": { ... },
"unit_types": { ... },
"building_types": { ... },
"vision_rules": {
"mountain_vision_bonus": 1,
"forest_blocks_vision": true,
"mountain_blocks_vision": true
},
"combat_rules": {
"base_damage_formula": "attacker.attack",
"adjacent_only": true,
"attack_model": "soft_hard",
"terrain_bonus_enabled": true,
"building_bonus_enabled": true,
"terrain_building_stack": "max",
"ambush_bonus": 25,
"suppression_enabled": true,
"global_suppression_ratio": 0.0,
"pinned_threshold": 1,
"friendly_fire_enabled": false,
"friendly_fire_damage": 0.5
}
}
Value Behavior
"none" No factions. All players use the shared unit_types.
"optional" Players may pick a faction for unique units.
"required" Every player must select a faction before the game starts.

The top-level unit_types defines generic fallback units used only when no faction is selected. In faction games, each faction’s own unit_types completely replaces any shared unit with the same key:

{
"unit_types": {
"INFANTRY": { "name": "Infantry", "role": "INFANTRY", "health": 10, ... },
"VEHICLE": { "name": "Vehicle", "role": "ARMOR", "health": 15, ... }
},
"factions": {
"ALLIANCE": {
"unit_types": {
"INFANTRY": { "name": "Militia", "role": "INFANTRY", "health": 10, ... },
"VEHICLE": { "name": "Battle Tank", "role": "ARMOR", "health": 18, ... },
"HOWITZER": { "name": "Howitzer", "role": "RANGED", "health": 10, ... }
}
}
}
}

An Alliance player gets Militia, Battle Tank, and Howitzer — never the generic Infantry or Vehicle.


Maps are faction-agnostic — the map creator doesn’t know which factions will play. Spawn points use archetypes (a role + power tier) that resolve to the correct faction unit at game start.

Every unit definition includes a role field:

"HOWITZER": {
"name": "Howitzer",
"role": "RANGED",
"health": 10,
"min_attack_range": 2,
"max_attack_range": 4,
...
}
Role Description Current units
INFANTRY Foot soldiers, can capture buildings Militia, Trooper, Shock Trooper
ARMOR Heavy armored vehicles (tanks) Battle Tank, Assault Tank
RANGED Long-range indirect fire (artillery, rockets) Howitzer, Rocket Battery
ANTI_ARMOR (Reserved) Anti-tank / tank destroyers
RECON (Reserved) Fast scouts with high vision
VEHICLE (Reserved) Light vehicles (APCs, IFVs)
ENGINEER (Reserved) Builders, repair, field fortifications
AIR_DEFENSE (Reserved) Anti-air units
FIGHTER (Reserved) Air superiority aircraft
BOMBER (Reserved) Ground attack aircraft
NAVAL (Reserved) Surface warships

Units in the same role can have different power tiers:

"SHOCK_TROOPER": {
"name": "Shock Trooper",
"role": "INFANTRY",
"tier": 2,
"move_points": 4,
"allow_move_after_attack": true,
...
}
Tier Meaning Example
1 (default) Basic unit Trooper
2 Advanced variant Shock Trooper
3 Elite variant (future)

Units without a tier field are tier 1.

When a game starts, each spawn point resolves like this:

  1. Direct match — If the spawn value is a unit ID (e.g. "HOWITZER"), use it directly
  2. Role + tier scan — Find a faction unit with matching role and tier
  3. Tier fallback — No match at requested tier? Try the next lower tier, down to T1
  4. Last resort — No unit matches the role at all? Spawn the faction’s Infantry

Example: A map spawn is set to INFANTRY_2 (Infantry, tier 2):

Faction Has T2 infantry? Result
Federation Yes (Shock Trooper) Spawns Shock Trooper
Alliance No Falls back to T1 → Spawns Militia

In the map editor’s Spawns mode:

  1. Select a player slot (P1–P8)
  2. Pick a role — Infantry, Armor, or Ranged
  3. Pick a tier — T1, T2, or T3
  4. Click hexes to place spawn points

The spawn marker shows the role letter and tier (e.g. I for Infantry T1, R2 for Ranged T2).


A faction lives inside the ruleset’s factions block. Here’s the minimal structure:

"MY_FACTION": {
"id": "MY_FACTION",
"name": "The Ironclads",
"description": "Heavy defensive faction with powerful armor.",
"version": "1.0.0",
"difficulty": "intermediate",
"playstyle_tags": ["defensive", "armor-heavy"],
"emblem": { "type": "emoji", "value": "🛡️" },
"asset_pack": "default",
"availability": {
"game_modes": [],
"seasonal": null,
"event_locked": ""
},
"unlock_requirements": {
"default_unlocked": true,
"premium_only": false,
"required_trophies": []
},
"preview": {
"tagline": "Unbreakable line",
"showcase_units": ["INFANTRY", "HEAVY_TANK"],
"unlock_hint": "Available by default"
},
"relationships": {
"counters": [],
"countered_by": [],
"lore_enemies": []
},
"unit_types": {
"INFANTRY": {
"name": "Garrison",
"role": "INFANTRY",
"symbol": "G",
"health": 12,
"move_points": 2,
"attack": 3,
"soft_attack": 3,
"hard_attack": 2,
"target_type": "SOFT",
"splash_damage": 0,
"defense": 0,
"shots_per_attack": 1,
"aiming_spread": 0,
"area_target_radius": 0,
"vision_range": 2,
"can_traverse_all": true,
"can_capture": true,
"cost": 150,
"build_time": 1,
"attack_cost": 0,
"min_attack_range": 1,
"max_attack_range": 1,
"max_attacks_per_turn": 1,
"allow_move_after_attack": false,
"allow_attack_after_move": true,
"suppression_ratio": 0.0,
"can_be_suppressed": true
}
},
"building_types": {},
"bonuses": {
"starting_gold_bonus": 0,
"income_multiplier": 1.0,
"unit_cost_multiplier": 1.0,
"vision_range_bonus": 0
}
}
Field Type Description
name string Display name (also used for sprite lookup)
role string Archetype — see roles table above
tier int Power tier within role (omit or 0 for tier 1)
symbol string 1-2 character fallback when no sprite is available
health int Maximum hit points
move_points int Movement points per turn
attack int Legacy attack value (used when soft/hard are both 0)
soft_attack int Damage vs soft targets (infantry, artillery)
hard_attack int Damage vs hard targets (vehicles, tanks)
target_type string "SOFT" or "HARD" — what this unit counts as when defending
splash_damage int (Reserved) Area-of-effect damage — not yet implemented
defense int (Reserved) Defense modifier — not yet implemented
shots_per_attack int (Reserved) Projectiles per attack action (default 1) — not yet implemented
aiming_spread float (Reserved) Shot distribution within target area (0.0 = perfect) — not yet implemented
area_target_radius int (Reserved) Targetable area in hexes (0 = single tile) — not yet implemented
vision_range int How far this unit can see (in hexes)
can_traverse_all bool Can cross mountains, shallow water, and other vehicle-impassable terrain
can_capture bool Can capture buildings
cost int Gold cost to produce
build_time int Turns to produce (1 = instant, 2+ = queued production)
min_attack_range int Minimum attack range (1 = adjacent)
max_attack_range int Maximum attack range (1 = melee only)
attack_cost int Movement points consumed per attack (0 = free)
max_attacks_per_turn int How many times this unit can attack per turn
allow_move_after_attack bool Can move after attacking
allow_attack_after_move bool Can attack after moving
suppression_ratio float Fraction of dealt damage converted to suppression (0.0-1.0)
can_be_suppressed bool Whether this unit can receive suppression effects

Fields marked (Reserved) exist in the schema and should be included with their default values (0 or false) for forward compatibility, but have no gameplay effect yet.

The suppression system is split between unit-level and combat-level settings:

  • Unit-level
    • suppression_ratio: how much of this unit’s damage becomes suppression instead of hard HP loss
    • can_be_suppressed: whether this unit can receive suppression
  • Combat-level (combat_rules)
    • suppression_enabled: master switch
    • global_suppression_ratio: fallback when a unit does not define suppression_ratio
    • pinned_threshold: effective strength at or below which units cannot counter-attack

This lets you make artillery heavily suppression-oriented while keeping direct-fire units mostly hard-damage focused.

  • Include at least one unit with "role": "INFANTRY" — this is the last-resort spawn fallback
  • Cover the core roles (INFANTRY, ARMOR, RANGED) so maps with varied archetypes work
  • Give each unit a unique symbol (1-2 characters) for the fallback renderer
  • Faction bonuses are multipliers — 1.0 means no change from baseline

Themes control the visual appearance: terrain tiles, unit sprites, building graphics, overlays, and animations. The game ships with a default theme and falls back to colored shapes for any missing assets.

A theme lives in public/themes/{theme-name}/ and contains a JSON manifest plus asset subdirectories. Every asset follows the naming pattern {name}_v{variant}_f{frame}.png:

themes/my-theme/
theme.json ← Manifest (declares assets and animations)
terrain/
grass/
grass_v1_f1.png ← Variant 1, Frame 1 (static)
grass_v2_f1.png ← Variant 2 (another static look)
water/
water_v1_f1.png ← Animated: frame 1
water_v1_f2.png ← Animated: frame 2
forest/
forest_v1_f1.png
...
overlays/
road/
road_isolated_v1_f1.png ← Autotile shapes for roads
road_straight_ew_v1_f1.png
road_bend60_e_ne_v1_f1.png
...
river/
river_source_v1_f1.png
river_straight_v1_f1.png
...
units/
militia_v1_f1.png
battle_tank_v1_f1.png
howitzer_v1_f1.png
...
buildings/
base_v1_f1.png
factory_v1_f1.png
city_v1_f1.png
...

Key conventions:

  • v{N} = variant number (for visual variety — multiple grass looks, etc.)
  • f{N} = frame number (multiple frames = animation, single frame = static)
  • Filenames are lowercase with underscores
  • Terrain tiles go in subfolders per type; units and buildings are flat

The default theme uses 64×64 pixel tiles. Declare your dimensions in theme.json:

{
"tile_dimensions": {
"width": 64,
"height": 64,
"hex_size": 64
}
}

The manifest declares every asset, its variants, and animation properties. Here’s a trimmed example matching the default theme:

{
"id": "my-theme",
"name": "My Theme",
"version": "1.0.0",
"tile_dimensions": { "width": 64, "height": 64, "hex_size": 64 },
"default_fps": 8,
"terrain": {
"GRASS": {
"variants": {
"1": { "frames": ["/themes/my-theme/terrain/grass/grass_v1_f1.png"], "fps": 0, "animated": false },
"2": { "frames": ["/themes/my-theme/terrain/grass/grass_v2_f1.png"], "fps": 0, "animated": false }
}
},
"SHALLOW_WATER": {
"variants": {
"1": {
"frames": [
"/themes/my-theme/terrain/shallow_water/shallow_water_v1_f1.png",
"/themes/my-theme/terrain/shallow_water/shallow_water_v1_f2.png",
"/themes/my-theme/terrain/shallow_water/shallow_water_v1_f3.png"
],
"fps": 1,
"animated": true
}
}
}
},
"overlays": {
"ROAD": {
"autotile": true,
"shapes": {
"isolated": { "variants": { "1": { "frames": ["...road_isolated_v1_f1.png"], "animated": false } } },
"straight_ew": { "variants": { "1": { "frames": ["...road_straight_ew_v1_f1.png"], "animated": false } } },
"bend60_e_ne": { "variants": { "1": { "frames": ["...road_bend60_e_ne_v1_f1.png"], "animated": false } } }
}
}
},
"units": {
"MILITIA": { "variants": { "1": { "frames": ["/themes/my-theme/units/militia_v1_f1.png"], "animated": false } } },
"BATTLE_TANK": { "variants": { "1": { "frames": ["/themes/my-theme/units/battle_tank_v1_f1.png"], "animated": false } } }
},
"buildings": {
"BASE": { "variants": { "1": { "frames": ["/themes/my-theme/buildings/base_v1_f1.png"], "animated": false } } },
"FACTORY": { "variants": { "1": { "frames": ["/themes/my-theme/buildings/factory_v1_f1.png"], "animated": false } } }
}
}

Unit sprite keys in theme.json are matched by the unit’s display name (uppercased, spaces replaced with underscores):

Unit name theme.json key Sprite file
Militia MILITIA militia_v1_f1.png
Battle Tank BATTLE_TANK battle_tank_v1_f1.png
Shock Trooper SHOCK_TROOPER shock_trooper_v1_f1.png
Rocket Battery ROCKET_BATTERY rocket_battery_v1_f1.png

If the name-based sprite isn’t found, the game tries the unit type ID (e.g. INFANTRY). If neither exists, it renders a colored circle with the unit’s symbol text.

Overlays are terrain-agnostic features drawn on top of base terrain (roads, rivers). They are defined in both the ruleset’s overlay_types and the theme’s overlays section.

Road overlays use an autotile system — the game automatically picks the correct shape (straight, bend, T-junction, crossroads, etc.) based on neighboring road tiles. The default theme ships with 20+ road shapes.

River overlays use a path-based autotile method — rivers are ordered sequences of tiles with source, mouth, straight, and curve shapes.

For detailed sprite dimensions and hex geometry, see the Graphics Guide.


Field Type Description
name string Display name
move_cost float Movement point cost to enter (-1 = impassable)
passable bool Can infantry/foot units enter
vehicle_passable bool Can vehicles/armor enter
naval_passable bool (Future) Can naval units enter
air_passable bool (Future) Can air units enter
vision_blocking bool Blocks line-of-sight rays
income int (Legacy) Gold per turn when owned — income now comes from buildings
defense_bonus int % damage reduction for a defender on this terrain
attack_bonus int % damage bonus for an attacker on this terrain
Field Type Description
name string Display name
move_cost_modifier float Effective move cost (replaces or modifies base terrain cost)
stacking string How cost is applied: "replace" (default), "multiply", "add"
passable bool Can infantry enter
vehicle_passable bool Can vehicles enter
naval_passable bool (Future) Can naval units enter
air_passable bool (Future) Can air units enter
vision_blocking bool Blocks line-of-sight
compatible_terrains [string] Base terrains this overlay can exist on (empty = any passable)
incompatible_terrains [string] Base terrains this overlay cannot exist on
Field Type Description
name string Display name
income int Gold generated per turn when owned
can_produce bool Can build units here
producible_units [string] Unit types this building can produce (empty = all available)
capture_required int Turns to capture
defense_bonus int Combat defense modifier (%)
upgrade_to string Building type this can upgrade to (empty = none)
upgrade_cost int Gold cost to upgrade
upgrade_turns int Turns to complete upgrade
placement_rules object Where this building can be placed (see below)
Field Type Description
allowed_terrains [string] Terrain types where building can be placed (empty = any)
forbidden_terrains [string] Terrain types where building cannot be placed
requires_adjacent [string] Must be adjacent to at least one of these terrain types
requires_distance_from [object] (Future) Minimum distance from other buildings
max_per_map int (Future) Maximum count per map (0 = unlimited)
min_distance_from_edge int (Future) Minimum distance from map edge
Field Type Description Default
mountain_vision_bonus int Extra vision range for units on mountains 1
forest_blocks_vision bool Forests block line-of-sight true
mountain_blocks_vision bool Mountains block line-of-sight true
Field Type Description Default
base_damage_formula string Damage formula identifier "attacker.attack"
adjacent_only bool Restrict combat to adjacent hexes only true
attack_model string "single" or "soft_hard" (choose attack stat by target type) "soft_hard"
terrain_bonus_enabled bool Apply terrain defense/attack bonuses true
building_bonus_enabled bool Apply building defense bonus in combat true
terrain_building_stack string How terrain + building bonuses combine: "max" or "add" "max"
ambush_bonus int % bonus for ambush attacks 25
suppression_enabled bool Master switch for suppression system true
global_suppression_ratio float Fallback suppression ratio when unit has none set 0.0
pinned_threshold int Effective strength at or below which counter-attack is disabled 1
friendly_fire_enabled bool (Future) Whether area attacks can damage allies false
friendly_fire_damage float (Future) Damage multiplier for friendly fire 0.5
Field Type Description Default
starting_gold_bonus int Extra gold at game start 0
income_multiplier float Multiplier on building income (1.0 = no change) 1.0
unit_cost_multiplier float Multiplier on unit costs (< 1.0 = discount) 1.0
vision_range_bonus int Extra vision range for all faction units 0

Role (role value) Alliance T1 Alliance T2 Federation T1 Federation T2
INFANTRY Militia Trooper Shock Trooper
ARMOR Battle Tank Assault Tank
RANGED Howitzer Rocket Battery
ID Passable Vehicle Notes
GRASS Yes Yes Standard terrain, move cost 1
FOREST Yes Yes Move cost 2, blocks vision
MOUNTAIN Yes No Move cost 2, blocks vision, +1 vision bonus
SHALLOW_WATER Yes* No Move cost 2, *infantry only (can_traverse_all)
DEEP_WATER No No Impassable to land units
WATER No No (Deprecated — use DEEP_WATER)
BASE Yes Yes Terrain under base buildings
EMPTY No No Void / off-map
ID Effect Autotile Notes
ROAD Move cost 0.5 (replaces base terrain cost) Yes Compatible with GRASS, FOREST, MOUNTAIN
ID Income Produces Defense Notes
BASE 100 Yes 10% Upgrades to HQ
HQ 200 Yes 25% Top-tier command center
FACTORY 150 Yes 15% Production powerhouse
CITY 50 No 15% Economic point
AIRFIELD 100 Yes 10% Flat terrain only
SEAPORT 100 Yes 10% Must be adjacent to water
OUTPOST 75 No 5% Can be placed anywhere
DEPOT 125 No 10% Economic objective
Map spawn: "RANGED_2" (Ranged, tier 2)
├─ Faction has RANGED tier 2? → Yes → Spawn it
│ → No → Try tier 1
├─ Faction has RANGED tier 1? → Yes → Spawn it
│ → No → Spawn Infantry T1
└─ Done

As new units and factions are added, the archetype system picks them up automatically — existing maps don’t need updates.