Sunday, February 8, 2026

git-game 2

 license: public domain CC0

Git-Game MMO on a Temporal Effect Runtime

Replayable mechanics, versioned rules, and task-centric debugging


1. Motivation

Git‑game as an MMO is:

  • a long‑lived, evolving world,
  • driven by player actions that look like Git/CI/infra operations,
  • full of edge cases, exploits, and emergent behavior,
  • season‑based, with changing rules and late‑game weirdness.

We want:

  • “Regular programming” for v1 of the game,
  • Replayable mechanics for debugging and lore,
  • Versioned rules for balance patches and seasons,
  • Shadow testing and percent rollout for new mechanics,
  • Task‑centric debugging for hard‑to‑reproduce bugs.

The temporal effect + versioned function system is a natural fit.


2. Problem space

2.1 MMO-specific challenges

  • Evolving rules: merge, rebase, conflict resolution, CI behavior all change over time.
  • Exploits: players will find degenerate strategies and bugs.
  • Scale: many concurrent players, shards, and background jobs.
  • Lore: past events should be inspectable, replayable, and narrativized.

2.2 What we need from the runtime

  • Replayable effects for key decisions (not everything).
  • Versioned mechanics with type‑safe evolution.
  • Shadow testing of new rules on live traffic.
  • Per‑task replay for debugging and simulation.
  • Config‑driven routing between versions (no call‑site changes).

3. Abstract goals for git-game MMO

  1. Model core mechanics as pure(ish) functions in a game monad.
  2. Use a modern effect system (effectful/polysemy style) for IO, DB, time, RNG, etc.
  3. Introduce a Replay effect for opt‑in checkpoints and replays.
  4. Wrap core mechanics as Versioned functions with schemas/codecs.
  5. Use routing policies (primary/shadow/percent) to control which version runs.
  6. Store replay logs for selected tasks (incidents, famous events, player sessions).
  7. Expose tools for:
    • replaying tasks,
    • comparing versions,
    • debugging specific players/actions,
    • running offline simulations.

4. High-level architecture

4.1 Game monad and effects

We define a game monad with a small, explicit effect stack:

type GameM = Eff
  '[ Http      -- for external services, webhooks, etc.
   , DB        -- game state persistence
   , Clock     -- time
   , RNG       -- randomness
   , Replay    -- checkpoints & replay
   , Log       -- structured logging
   ]

Core effects:

data DB :: Effect where
  LoadWorld  :: ShardId -> DB m WorldState
  SaveWorld  :: ShardId -> WorldState -> DB m ()
  LoadPlayer :: PlayerId -> DB m PlayerState
  SavePlayer :: PlayerId -> PlayerState -> DB m ()

data Clock :: Effect where
  Now :: Clock m UTCTime

data RNG :: Effect where
  RandomInt :: (Int, Int) -> RNG m Int

data Replay :: Effect where
  Checkpoint :: Serializable a => Text -> a -> Replay m ()
  Restore    :: Serializable a => Text -> Replay m (Maybe a)

Helper:

replayable :: Serializable a => Text -> GameM a -> GameM a
replayable key action = do
  m <- Restore key
  case m of
    Just v  -> pure v
    Nothing -> do
      v <- action
      Checkpoint key v
      pure v

4.2 Core game mechanics as functions

Examples:

data Action
  = Commit CommitInput
  | Merge MergeInput
  | Rebase RebaseInput
  | OpenPR PROpenInput
  | RunCI CIInput
  -- ...

handleAction :: PlayerId -> Action -> GameM [WorldDelta]
handleAction pid action =
  case action of
    Commit ci -> handleCommit pid ci
    Merge  mi -> handleMerge  pid mi
    Rebase ri -> handleRebase pid ri
    OpenPR pi -> handleOpenPR pid pi
    RunCI  ci -> handleRunCI  pid ci

Key decision points are wrapped in replayable:

handleMerge :: PlayerId -> MergeInput -> GameM [WorldDelta]
handleMerge pid mi = do
  world <- LoadWorld (mergeShard mi)
  result <- replayable "resolveMerge" $
              resolveMerge world pid mi
  applyMergeResult world result

4.3 Versioned mechanics

We wrap core mechanics in Versioned:

data Version = Version
  { major :: Int
  , minor :: Int
  } deriving (Eq, Ord, Show)

data Versioned i o = Versioned
  { version      :: Version
  , runVersioned :: i -> GameM o
  , inputCodec   :: Codec i
  , outputCodec  :: Codec o
  }

-- Example: merge resolution
data MergeInput  = MergeInput { ... }
data MergeResult = MergeResult { ... }

resolveMerge_v1 :: Versioned MergeInput MergeResult
resolveMerge_v1 = Versioned
  { version      = Version 1 0
  , runVersioned = resolveMergeImplV1
  , inputCodec   = mergeInputCodec
  , outputCodec  = mergeResultCodec
  }

resolveMerge_v2 :: Versioned MergeInput MergeResult
resolveMerge_v2 = Versioned
  { version      = Version 2 0
  , runVersioned = resolveMergeImplV2
  , inputCodec   = mergeInputCodec
  , outputCodec  = mergeResultCodec
  }

We group versions into families:

data VersionedFamily where
  Family :: (Typeable i, Typeable o)
         => Map Version (Versioned i o)
         -> VersionedFamily

newtype Registry = Registry (Map Text VersionedFamily)

Registering:

registry :: Registry
registry = Registry $ fromList
  [ ("resolveMerge", Family (fromList
      [ (Version 1 0, resolveMerge_v1)
      , (Version 2 0, resolveMerge_v2)
      ]))
  -- other mechanics...
  ]

4.4 Routing policies (primary, shadow, percent)

data RoutingMode
  = Primary Version
  | Shadow Version Version
  | Percent Version Version Int  -- candidate percent

data FunctionRoute = FunctionRoute
  { name   :: Text
  , family :: VersionedFamily
  , mode   :: RoutingMode
  }

type RoutingTable = Map Text FunctionRoute

Example config (YAML-ish):

functions:
  resolveMerge:
    routing:
      mode: shadow
      primary: "1.0"
      shadow:  "2.0"

  resolveConflict:
    routing:
      mode: percent
      primary: "1.0"
      candidate: "2.0"
      percent: 10

Engine logic (sketch):

runVersionedWithRouting
  :: RoutingTable
  -> Text          -- function name
  -> i
  -> GameM o
runVersionedWithRouting table fname input = do
  let FunctionRoute{family, mode} = table ! fname
  case family of
    Family versions ->
      case mode of
        Primary v ->
          runVersioned (versions ! v) input

        Shadow primaryV shadowV -> do
          let primaryFn = versions ! primaryV
              shadowFn  = versions ! shadowV
          primaryOut <- runVersioned primaryFn input
          -- run shadow in background, log divergence
          _ <- forkShadow shadowFn input primaryOut
          pure primaryOut

        Percent primaryV candidateV pct -> do
          r <- RandomInt (1, 100)
          let chosenV = if r <= pct then candidateV else primaryV
          runVersioned (versions ! chosenV) input

5. Development & deployment flow

5.1 Version 1: “regular programming”

  • You write GameM functions like handleAction, resolveMergeImplV1, etc.
  • You register v1 versions in the registry.
  • You run the server with a simple routing table (all Primary v1).
main :: IO ()
main = runGameServer defaultConfig registryV1

This feels like a normal Haskell service.


5.2 Adding version 2 of a mechanic

You implement:

resolveMergeImplV2 :: MergeInput -> GameM MergeResult
resolveMergeImplV2 = -- new rules, maybe more logging, etc.

Wrap it as resolveMerge_v2, add to the family, and update routing config to:

  • shadow v2 against v1, or
  • send 5–10% of traffic to v2.

No call‑site changes.


5.3 Debugging a hard-to-reproduce bug

  1. A weird merge result occurs for player X on shard Y.

  2. The engine has stored a replay log for that task (because resolveMerge was replayable).

  3. You load the log and replay:

    debugMerge :: ReplayLog -> IO ()
    debugMerge log = runDebug $ replay log $ do
      result <- resolveMergeImplV2 (loggedInput log)
      Log.info ("Replay result: " <> show result)
    
  4. If needed, you create a debug variant:

    resolveMergeDebug :: MergeInput -> GameM MergeResult
    resolveMergeDebug mi = do
      Log.info ("Debug merge input: " <> show mi)
      r <- resolveMergeImplV2 mi
      Log.info ("Debug merge result: " <> show r)
      pure r
    
  5. You temporarily route only this task or this player to resolveMergeDebug via tooling.


5.4 MMO-scale operations

  • Shards: each shard runs its own GameM server with its own world state.
  • Routing per shard: you can roll out v2 of a mechanic to a subset of shards.
  • Cohorts: you can route new players to experimental mechanics.
  • Seasonal rules: you can define “Season 3” as a routing table that prefers v3 of certain mechanics.

6. Player-facing features powered by replay

Because we store replay logs for selected tasks:

  • Time-travel rooms: in-game museums where players can “watch” famous merges, outages, or wars—driven by actual replay logs.
  • Alternate timelines: “What if this PR had been merged under Season 1 rules?” → run both v1 and v3, show divergent outcomes.
  • Personal training sims: late‑game ability where players can replay their own last N actions in a sandbox.

These are not bolted‑on features—they fall out of the runtime’s core capabilities.


7. Concrete sketch: a small end-to-end flow

7.1 Types

data MergeInput = MergeInput
  { shardId   :: ShardId
  , base      :: CommitId
  , head      :: CommitId
  , playerId  :: PlayerId
  }

data MergeResult = MergeResult
  { success   :: Bool
  , conflicts :: [Conflict]
  , newCommit :: Maybe CommitId
  }

7.2 v1 implementation

resolveMergeImplV1 :: MergeInput -> GameM MergeResult
resolveMergeImplV1 mi = do
  world <- LoadWorld (shardId mi)
  -- simple, maybe naive rules
  pure (naiveMerge world mi)

7.3 v2 implementation

resolveMergeImplV2 :: MergeInput -> GameM MergeResult
resolveMergeImplV2 mi = do
  world <- LoadWorld (shardId mi)
  Log.info ("resolveMerge v2 for " <> show (playerId mi))
  -- smarter conflict resolution, new rules
  pure (smarterMerge world mi)

7.4 Versioned wrappers

resolveMerge_v1 :: Versioned MergeInput MergeResult
resolveMerge_v1 = Versioned
  { version      = Version 1 0
  , runVersioned = resolveMergeImplV1
  , inputCodec   = mergeInputCodec
  , outputCodec  = mergeResultCodec
  }

resolveMerge_v2 :: Versioned MergeInput MergeResult
resolveMerge_v2 = Versioned
  { version      = Version 2 0
  , runVersioned = resolveMergeImplV2
  , inputCodec   = mergeInputCodec
  , outputCodec  = mergeResultCodec
  }

7.5 Using it in handleMerge

handleMerge :: PlayerId -> MergeInput -> GameM [WorldDelta]
handleMerge pid mi = do
  -- replayable decision point
  result <- replayable "resolveMerge" $
              runVersionedWithRouting routingTable "resolveMerge" mi
  applyMergeResult (shardId mi) result

8. Game plan

  1. Phase 1: Core GameM + Effects

    • Define GameM, DB, Clock, RNG, Replay, Log.
    • Implement interpreters for production and test/replay.
  2. Phase 2: Versioned Mechanics

    • Introduce Version, Versioned i o, VersionedFamily, Registry.
    • Wrap key mechanics: merge, rebase, conflict resolution, CI.
  3. Phase 3: Routing & Shadow Testing

    • Implement RoutingMode, FunctionRoute, RoutingTable.
    • Add shadow and percent rollout for selected mechanics.
  4. Phase 4: Replay Logs & Tools

    • Implement replay log storage for selected tasks.
    • Build CLI/UI for:
      • listing tasks,
      • replaying tasks,
      • flipping routing for specific tasks/players.
  5. Phase 5: MMO Integration

    • Shard management (one GameM server per shard).
    • Cohort/season routing configs.
    • Player‑facing features (time‑travel rooms, alternate timelines).
  6. Phase 6: Iterate

    • Add more effects (metrics, tracing).
    • Add more versioned mechanics.
    • Explore multi‑process orchestration later if needed.

This gives you a path where:

  • v1 of git‑game MMO feels like “just write Haskell game logic,”
  • the engine’s powers (replay, versioning, shadowing) are opt‑in and policy‑driven,
  • and the late‑game fantasy—time‑bending, lore‑driven, rules‑shifting MMO—is directly powered by the same temporal effect system.

No comments:

Post a Comment