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
}
}
ValueBehavior
"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,
...
}
RoleDescriptionCurrent units
INFANTRYFoot soldiers, can capture buildingsMilitia, Trooper, Shock Trooper
ARMORHeavy armored vehicles (tanks)Battle Tank, Assault Tank
RANGEDLong-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,
...
}
TierMeaningExample
1 (default)Basic unitTrooper
2Advanced variantShock Trooper
3Elite 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):

FactionHas T2 infantry?Result
FederationYes (Shock Trooper)Spawns Shock Trooper
AllianceNoFalls 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
}
}
FieldTypeDescription
namestringDisplay name (also used for sprite lookup)
rolestringArchetype — see roles table above
tierintPower tier within role (omit or 0 for tier 1)
symbolstring1-2 character fallback when no sprite is available
healthintMaximum hit points
move_pointsintMovement points per turn
attackintLegacy attack value (used when soft/hard are both 0)
soft_attackintDamage vs soft targets (infantry, artillery)
hard_attackintDamage vs hard targets (vehicles, tanks)
target_typestring"SOFT" or "HARD" — what this unit counts as when defending
splash_damageint(Reserved) Area-of-effect damage — not yet implemented
defenseint(Reserved) Defense modifier — not yet implemented
shots_per_attackint(Reserved) Projectiles per attack action (default 1) — not yet implemented
aiming_spreadfloat(Reserved) Shot distribution within target area (0.0 = perfect) — not yet implemented
area_target_radiusint(Reserved) Targetable area in hexes (0 = single tile) — not yet implemented
vision_rangeintHow far this unit can see (in hexes)
can_traverse_allboolCan cross mountains, shallow water, and other vehicle-impassable terrain
can_captureboolCan capture buildings
costintGold cost to produce
build_timeintTurns to produce (1 = instant, 2+ = queued production)
min_attack_rangeintMinimum attack range (1 = adjacent)
max_attack_rangeintMaximum attack range (1 = melee only)
attack_costintMovement points consumed per attack (0 = free)
max_attacks_per_turnintHow many times this unit can attack per turn
allow_move_after_attackboolCan move after attacking
allow_attack_after_moveboolCan attack after moving
suppression_ratiofloatFraction of dealt damage converted to suppression (0.0-1.0)
can_be_suppressedboolWhether 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 nametheme.json keySprite file
MilitiaMILITIAmilitia_v1_f1.png
Battle TankBATTLE_TANKbattle_tank_v1_f1.png
Shock TrooperSHOCK_TROOPERshock_trooper_v1_f1.png
Rocket BatteryROCKET_BATTERYrocket_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.


FieldTypeDescription
namestringDisplay name
move_costfloatMovement point cost to enter (-1 = impassable)
passableboolCan infantry/foot units enter
vehicle_passableboolCan vehicles/armor enter
naval_passablebool(Future) Can naval units enter
air_passablebool(Future) Can air units enter
vision_blockingboolBlocks line-of-sight rays
incomeint(Legacy) Gold per turn when owned — income now comes from buildings
defense_bonusint% damage reduction for a defender on this terrain
attack_bonusint% damage bonus for an attacker on this terrain
fortification_bonusint% extra damage reduction vs ranged/indirect attacks
FieldTypeDescription
namestringDisplay name
move_cost_modifierfloatEffective move cost (replaces or modifies base terrain cost)
stackingstringHow cost is applied: "replace" (default), "multiply", "add"
passableboolCan infantry enter
vehicle_passableboolCan vehicles enter
naval_passablebool(Future) Can naval units enter
air_passablebool(Future) Can air units enter
vision_blockingboolBlocks 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
FieldTypeDescription
namestringDisplay name
incomeintGold generated per turn when owned
can_produceboolCan build units here
producible_units[string]Unit types this building can produce (empty = all available)
capture_requiredintTurns to capture
defense_bonusintCombat defense modifier (%)
upgrade_tostringBuilding type this can upgrade to (empty = none)
upgrade_costintGold cost to upgrade
upgrade_turnsintTurns to complete upgrade
placement_rulesobjectWhere this building can be placed (see below)
FieldTypeDescription
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_mapint(Future) Maximum count per map (0 = unlimited)
min_distance_from_edgeint(Future) Minimum distance from map edge
FieldTypeDescriptionDefault
mountain_vision_bonusintExtra vision range for units on mountains1
forest_blocks_visionboolForests block line-of-sighttrue
mountain_blocks_visionboolMountains block line-of-sighttrue
FieldTypeDescriptionDefault
base_damage_formulastringDamage formula identifier"attacker.attack"
adjacent_onlyboolRestrict combat to adjacent hexes onlytrue
attack_modelstring"single" or "soft_hard" (choose attack stat by target type)"soft_hard"
terrain_bonus_enabledboolApply terrain defense/attack/fortification bonusestrue
building_bonus_enabledboolApply building defense bonus in combattrue
terrain_building_stackstringHow terrain + building bonuses combine: "max" or "add""max"
ambush_bonusint% bonus for ambush attacks25
suppression_enabledboolMaster switch for suppression systemtrue
global_suppression_ratiofloatFallback suppression ratio when unit has none set0.0
pinned_thresholdintEffective strength at or below which counter-attack is disabled1
friendly_fire_enabledbool(Future) Whether area attacks can damage alliesfalse
friendly_fire_damagefloat(Future) Damage multiplier for friendly fire0.5
FieldTypeDescriptionDefault
starting_gold_bonusintExtra gold at game start0
income_multiplierfloatMultiplier on building income (1.0 = no change)1.0
unit_cost_multiplierfloatMultiplier on unit costs (< 1.0 = discount)1.0
vision_range_bonusintExtra vision range for all faction units0

Role (role value)Alliance T1Alliance T2Federation T1Federation T2
INFANTRYMilitiaTrooperShock Trooper
ARMORBattle TankAssault Tank
RANGEDHowitzerRocket Battery
IDPassableVehicleNotes
GRASSYesYesStandard terrain, move cost 1
FORESTYesYesMove cost 2, blocks vision
MOUNTAINYesNoMove cost 2, blocks vision, +1 vision bonus
SHALLOW_WATERYes*NoMove cost 2, *infantry only (can_traverse_all)
DEEP_WATERNoNoImpassable to land units
WATERNoNo(Deprecated — use DEEP_WATER)
BASEYesYesTerrain under base buildings
EMPTYNoNoVoid / off-map
IDEffectAutotileNotes
ROADMove cost 0.5 (replaces base terrain cost)YesCompatible with GRASS, FOREST, MOUNTAIN
IDIncomeProducesDefenseNotes
BASE100Yes10%Upgrades to HQ
HQ200Yes25%Top-tier command center
FACTORY150Yes15%Production powerhouse
CITY50No15%Economic point
AIRFIELD100Yes10%Flat terrain only
SEAPORT100Yes10%Must be adjacent to water
OUTPOST75No5%Can be placed anywhere
DEPOT125No10%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.