Skip to content

How to Build: Shared Game Data

Every system in the game -- combat, inventory, vendors, skills -- needs to agree on what a "Copper Sword" is, how much it costs, and what stats it gives. Without a single source of truth, you end up with magic numbers scattered across dozens of files, and changing a sword's attack bonus means hunting through three packages hoping you found every reference.

I wanted to solve this once, cleanly, so the rest of the codebase could just import what it needs and move on. The answer is a shared data layer: typed interfaces, flat data arrays, and a handful of pure lookup functions. No runtime dependencies, no computed fields, no cleverness. Just data.

The shape of the data

We need three categories of game data: gear the player can buy and equip, enemies the player fights, and consumables like potions. Let's define the interfaces for each.

Gear definitions

Every piece of equipment -- weapons and armor -- follows this shape:

typescript
interface GearDefinition {
  id: string
  name: string
  type: 'weapon' | 'armor'
  cost: number                    // gold price at the vendor
  attackSpeed?: number            // ticks per attack (weapons only)
  attackRange?: number            // hex range (weapons only)
  minHit?: number                 // minimum damage roll (weapons only)
  maxHit?: number                 // maximum damage roll (weapons only)
  bonuses: {
    attack?: number
    strength?: number
    defence?: number
  }
}

Notice how weapon-specific fields (attackSpeed, attackRange, minHit, maxHit) are optional. Armor simply omits them. The bonuses object keeps stat modifications uniform across both types -- the combat system can read bonuses without caring whether it's a sword or a chestplate.

The actual data is a flat array. Here's what the first few entries look like to give you the flavor:

typescript
export const GEAR: GearDefinition[] = [
  { id: 'copper_dagger', name: 'Copper Dagger', type: 'weapon', cost: 5,
    attackSpeed: 3, attackRange: 1, bonuses: { attack: 1, strength: 1 } },
  { id: 'copper_sword', name: 'Copper Sword', type: 'weapon', cost: 10,
    attackSpeed: 5, attackRange: 1, bonuses: { attack: 2, strength: 3 } },
  { id: 'iron_greatsword', name: 'Iron Greatsword', type: 'weapon', cost: 40,
    attackSpeed: 8, attackRange: 2, bonuses: { attack: 1, strength: 8 } },
  // Armor
  { id: 'copper_helm', name: 'Copper Helm', type: 'armor', cost: 8,
    bonuses: { defence: 2 } },
  { id: 'iron_chest', name: 'Iron Chestplate', type: 'armor', cost: 30,
    bonuses: { defence: 7 } },
]

The pattern here is deliberate: slow weapons hit harder (greatsword has 8 strength but takes 8 ticks), fast weapons are weaker but more responsive. Armor only provides defence bonuses. Keep this flat and obvious -- you'll be tuning these numbers constantly during playtesting.

Enemy profiles

Each enemy type in the game is described by a profile:

typescript
interface EnemyProfile {
  id: string
  name: string
  tier: number
  introducedAtLevel: number   // level within tier where this enemy starts appearing
  baseStats: {
    attack: number
    strength: number
    defence: number
    hp: number
    attackSpeed: number       // ticks per attack
  }
  goldDrop: { min: number; max: number }
}

The tier and introducedAtLevel fields control enemy progression. Tier 1 enemies appear in the first area, tier 2 in the second, and so on. Within a tier, introducedAtLevel staggers when each variant shows up so the player encounters new enemies as they progress through a level.

Consumables

Potions and other usable items. For the prototype, we only need one:

typescript
interface ConsumableDefinition {
  id: string
  name: string
  cost: number
  effect: { type: 'heal'; amount: number }
}

export const CONSUMABLES: ConsumableDefinition[] = [
  { id: 'hp_potion', name: 'HP Potion', cost: 3, effect: { type: 'heal', amount: 10 } },
]

The effect field uses a discriminated union pattern (type: 'heal'). When you eventually add mana potions or buff scrolls, you extend this union rather than bolting new fields onto the interface.

Balance constants

Three constant objects control the core game balance. Use as const so TypeScript narrows the types:

typescript
export const COMBAT = {
  TICK_RATE_MS: 250,
  MAX_SIMULTANEOUS_ATTACKERS: 32,
  AGGRO_RADIUS_HEX: 2,
  MIN_DAMAGE: 1,
} as const

export const PLAYER = {
  INITIAL_SKILLS: { attack: 1, strength: 1, defence: 1, hitpoints: 10 },
  HP_REGEN_TICKS: 4,        // ticks between regen while out of combat
  HP_REGEN_AMOUNT: 1,
} as const

export const ECONOMY = {
  BOSS_GOLD_MULTIPLIER: 5,
} as const

COMBAT.TICK_RATE_MS at 250 means 4 combat ticks per second. AGGRO_RADIUS_HEX at 2 means enemies engage when you're within two hex tiles. These numbers will shift during development, but having them all in one place means balance changes are a single-file edit.

The experience point curve

Skill progression uses an exponential curve. The function takes a level number and returns the experience points required to reach that level:

typescript
export function xpForLevel(level: number): number {
  return Math.floor(100 * Math.pow(1.1, level - 1))
}

Level 2 requires 100 experience points. Level 10 requires about 236. Level 50 requires about 11,739. The 1.1 exponent base means each level takes roughly 10% more experience points than the last -- fast early progression that gradually slows without ever feeling impossible. You can tune the base and the multiplier independently: raise 100 to make early levels slower, raise 1.1 to steepen the late-game curve.

Lookup helpers

Other systems need to find items by identifier. Rather than scattering .find() calls everywhere, centralize them:

typescript
export function getGearById(id: string): GearDefinition | undefined
export function getConsumableById(id: string): ConsumableDefinition | undefined
export function getEnemyProfile(id: string): EnemyProfile | undefined

There's also one derived calculation that multiple systems need -- combining a player's skill levels with their equipment bonuses to get effective combat stats:

typescript
export function getEffectiveStats(
  skills: { attackLevel: number; strengthLevel: number; defenceLevel: number },
  equipment: { weapon: string | null; armor: string | null },
): { attack: number; strength: number; defence: number }

This function looks up the equipped gear definitions and sums their bonuses with the raw skill levels. The combat system calls it on every tick, the user interface calls it when displaying stats, and the equipment menu calls it to show "what would change if I equipped this?" previews. Having one function means the math is always consistent.

File structure

shared/game-data/
├── types.ts           # GearDefinition, EnemyProfile, ConsumableDefinition interfaces
├── gear.ts            # GEAR array
├── enemies.ts         # ENEMY_PROFILES array
├── consumables.ts     # CONSUMABLES array
├── constants.ts       # COMBAT, PLAYER, ECONOMY constant objects
├── xp.ts              # xpForLevel function
├── helpers.ts         # getGearById, getConsumableById, getEnemyProfile, getEffectiveStats
└── index.ts           # Re-exports everything

One file per concern. types.ts holds all the interfaces. Each data category gets its own file exporting a single typed array. constants.ts groups the balance knobs. helpers.ts has the lookup functions. index.ts re-exports everything so consumers can write:

typescript
import { GEAR, COMBAT, ENEMY_PROFILES } from '@endless-idle/shared/game-data'

Putting it together

Start by writing all the interfaces in types.ts. Then create each data file one at a time, making sure TypeScript catches any shape mismatches immediately. Add the constants, then the experience point curve, then the helpers. Finally, verify that workspace imports resolve correctly from packages/game -- if import { GEAR } from '@endless-idle/shared/game-data' compiles cleanly, you're done.

This layer has zero runtime dependencies. It's pure data and pure functions. Every other system in the game imports from here, which means getting these types right saves you debugging time across the entire codebase.