ONLINE
CYBERSPACE://OMGninjabot/connect.sh
SYS: INIT...
NET: CONN...
                 ⢀⡠⠤⠔⠒⠒⠦⠄⣀                    
               ⡠⠚⠁         ⠉⠢⡀                 
    ⣠⣤⣤⡄     ⣸⠁  ⣀       ⣀  ⢹⡄                
  ⠘⣏⣀⣤⣾⡄   ⢠⡇⡰⠲⣯⣀⣀⡀  ⣀⣀⣤⠷⠲⡀⣇                
   ⢹⠯⣤⣞⣳⡀  ⢸ ⣇   ⠉⠉⢉⠉⢉⡉    ⡇⢸                
    ⢷⠧⣴⣏⣇  ⢸ ⢹⠒⣦⣤⣄⣀⣥⣖⣉⣤⣤⠔⢺ ⢸                
    ⠘⡟⢠⡴⣿⡆ ⢸ ⢸⡀⠙⢭⣽⣾⣀⣼⣿⣭⠝⠁⣸ ⢸                
     ⢱⡛⣲⠯⣽⡄⢸ ⠏⢱⠴⠊⠁   ⠈⠉⠲⣴⠙ ⣸                
      ⢳⣳⣞⣷⣷⣸⡇⣞⠁   ⢀       ⢹ ⡟                 
     ⢀⣈⠿⣟⣫⢭⡟⠣⠸⣦   ⠈⠉⠉   ⢀⣾⢠⠛⣦⢄⣀             
   ⣀⣴⠿⢤⣼⡃ ⠈⢧  ⠳⡱⣄   ⢀⣿⣿⣤⣿⠃ ⢠⠇ ⡿⠤⠤⣤⣀⡀       
  ⢘⣿⠶⣄ ⣿⣇   ⠳⣀ ⠈⠙⠓⠦⢤⣿⡟⣻⠟⠁⢀⡠⠃  ⣰⠃⢀⠾⢿⣻      
 ⢀⡾⠁ ⠈⢧⣼⡟⣆         ⢸⢻⠁⠙⡇ ⠈   ⣰⠃ ⢠⡏   ⠹⡆     
 ⢸⡇   ⠈⣧⢣⠙⢦        ⡸⡞ ⠛⣇⣀   ⡴⠃  ⡞      ⣿⡄    
⢀⡟⢧  ⣀⠤⠾⡈⢆⠈⠙⢦⣀    ⢰⣧⣤⡦⠶⠧⠼⢤⣠⠞⠁  ⢸⠧⣄⡀ ⢸⠹⡄   
⣼ ⠘⡆⠊⡀  ⠙⣌⢢⡀ ⠈⠙⢶⣒⡶⠋⠙⡌⢧   ⠚⠳⡄   ⣾⠎ ⠙   ⢧⢹⡀  
⡏   ⡰    ⢸⣄⢑⣄ ⢀⡠⢿⡱⡀ ⡽⣸⣄⣷⠴⡄⢰⡇  ⢠⠃   ⢰    ⠉⢧  
⣧⢠  ⣇⣠⣤⠖⠋⠉⠁     ⠈⢧⠑⣴⠃⡿⠤⡽⠒⠒⠋   ⣾    ⠈⡆   ⠘⣼⡀
⢹⢸ ⢀⣽⡇⢸       ⢀⡠⠂ ⠳⣌⣾⡦⣞⠁      ⢻  ⢀⣴⡚⠳⣄    ⠘⡇
 ⢻⡇⠸⠁⢧ ⢇  ⣀⡠⠴⠚⠉   ⢠⠟⠉  ⠙⢦⡀   ⢀⡸⠗⠉  ⠱⡀⠹⡉⠳⠄⢸ 
 ⠈⡇  ⠈⢧⡈⢦       ⢀⡴⢛⣟⡭⠿⠿⠿⢿⡿⡿⠖⠒⠉      ⢣ ⡇  ⠘⡇ 
  ⣷   ⠈⠳⣄⠑⢄⡀ ⣀⠤⢺⠿⢂⣎⡏     ⢹⣽⡄ ⣀⣀⣀ ⣀⣀⡀⠸    ⢠⠇
  ⠘⠷⣄⣀⣀⣀⣉⣷⠤⠽⠋⠁⢠⡧⠔⣻⡏⣇      ⡇⡇        ⢀⠃ ⢀⡴⠋ 
              ⠸⡤⠚⠁⣴⣜⠦⣤⣤⣤⣤⣴⣧⢇⣀⡀   ⢀⣀⣀⣼⣀⠟⠉    
               ⠑⠢⠤⠤⠛⠉⠉⠉⠉⠉⠁⠁  ⠈⠉⠉⠉⠁           
            ⢀⣠ ⠖⠒⠒⠒    ⠠⣀ ⢀ ⡀  ⠂              
  ██████  ███    ███  ██████  ██
 ██    ██ ████  ████ ██       ██
 ██    ██ ██ ████ ██ ██   ███ ██
 ██    ██ ██  ██  ██ ██    ██   
  ██████  ██      ██  ██████  ██

 ███    ██ ██ ███    ██      ██  █████  ██████   ██████  ████████
 ████   ██ ██ ████   ██      ██ ██   ██ ██   ██ ██    ██    ██   
 ██ ██  ██ ██ ██ ██  ██      ██ ███████ ██████  ██    ██    ██   
 ██  ██ ██ ██ ██  ██ ██ ██   ██ ██   ██ ██   ██ ██    ██    ██   
 ██   ████ ██ ██   ████  ██████ ██   ██ ██████   ██████     ██   
                          /[-])//  ___         
                     __ --\ `_/~--|  / \       
                   /_-/~~--~~ /~~~\\_\ /\      
                   |  |___|===|_-- | \ \ \     
 _/~~~~~~~~|~~\,   ---|---\___/----|  \/\-\    
 ~\________|__/   / // \__ |  ||  / | |   | |  
          ,~-|~~~~~\--, | \|--|/~|||  |   | |  
          [3-|____---~~ _--'==;/ _,   |   |_|  
                      /   /\__|_/  \  \__/--/  
                     /---/_\  -___/ |  /,--|   
                     /  /\/~--|   | |  \///    
                    /  / |-__ \    |/          
                   |--/ /      |-- | \         
                  \^~~\\/\      \   \/- _      
                   \    |  \     |~~\~~| \     
                    \    \  \     \   \  | \   
                      \    \ |     \   \    \  
                       |~~|\/\|     \   \   |  
                      |   |/         \_--_- |\ 
                      |  /            /   |/\/ 
                       ~~             /  /     
                                     |__/      

cat deck_gameplay.txt

I've spent the last couple weeks turning the cyberdeck from "a thing that talks to you in cyberpunk-flavored ways" into something that's actually a game. Now there's a real loop: you take contracts from fixers, you run them through one of several gameplay modes, the world reacts, your reputation moves, and eventually an endgame storyline becomes available. In this post I want to walk through the parts I'm proudest of and the parts that fought back the hardest.

Interface upgrades

To support the narrative structure of missions, I finished implementing the BBS tab and added a few new ones. The BBS now fills itself in with chatter, but scattered throughout the chatter are mission clues and intel that can help unlock access to new fixers, expanding your access to new contracts.

BBS Home

BBS Messages

STREET is a new location where RPG elements outside of the deck can unfold: get in a gunfight with a gangoon, buy daemons at a local shop, or resolve narrative mission tasks like meeting contacts. All interactions are still driven through the text interface.

STREET

CODEX is a journal that tracks facts learned about fixers and missions. CODEX entries are automatically unlocked when the associated fact is revealed.

CODEX

INV is an inventory manager where you can equip weapons and armor as well as daemons for hacking runs. Nothing magical here, but an important addition.

Inventory

Missions are just JSON

The whole game runs through a thing I call the mission graph. Every contract has an optional node_graph field, and the graph defines the mission's structure: where it starts, which nodes connect to each other, and what happens when each node resolves. The engine doesn't know anything about specific contracts; it just walks the graph.

There are six node types:

Type What it is
narrative Read text, optionally pick a choice
intrusion Jacked-in network breach (subgame)
combat STREET-side weapons combat (subgame)
investigation Talk to your loaded core to surface facts
social DM with an NPC under mission pressure
resource Spend money / item, or pass a stat check
outcome Terminal node - mission ends with this status

A simple data-retrieval mission ends up looking like this:

{
  "node_graph": {
    "start": "brief",
    "nodes": {
      "brief": {
        "type": "narrative",
        "text": "Subnet address. Target file hash. Seven-minute window.",
        "choices": [
          {"label": "Acknowledge", "next": "run"},
          {"label": "Ask about backup", "next": "no_backup"}
        ]
      },
      "no_backup": {
        "type": "narrative",
        "text": "\"There is no backup window.\"",
        "next": "run"
      },
      "run": {
        "type": "intrusion",
        "title": "Helix Node",
        "topology": {
          "shape": "linear", "size": 6,
          "ice_pool": ["barrier", "trace"],
          "objectives": [{"type": "extract", "count": 1}]
        },
        "on_success": "debrief",
        "on_partial": "debrief",
        "on_fail": "burned"
      },
      "debrief": {
        "type": "narrative",
        "text": "The file lands. Wallet ticks up. No thanks. That's how it works.",
        "next": "win"
      },
      "burned": {
        "type": "narrative",
        "text": "The trace caught you mid-pull.",
        "next": "lose"
      },
      "win":  {"type": "outcome", "outcome": "success"},
      "lose": {"type": "outcome", "outcome": "fail"}
    }
  }
}

Authoring a new mission is mostly an exercise in writing a JSON file. The MissionRunner queues up subgames when it hits an intrusion or combat node, and routes to whichever next-node the outcome demands.

One thing I've made a commitment to: player agency. The mission engine never auto-takes-over. If your current node is an intrusion, the game doesn't drop you into the hacking subgame uninvited. It just queues the intrusion and tells you to type connect on the CON tab when you're ready. Same for combat (fight on STREET), investigation (chat with your core on AICI), social nodes (dm <fixer> on BBS), and resource nodes (pay or skip on STREET). The mission state hangs in the air, the relevant tab nudges you, and the player decides when to engage.

This sounds simple but it took some iteration. Earlier prototypes had the engine yanking the user into combat the moment the previous narrative node finished. It felt like the game was driving and the player was a passenger. Forcing player-initiated transitions made the whole thing feel calmer. You read the brief at your own pace. You walk over to STREET when you're ready. The fiction holds its breath while you do.

Combat is rock-paper-scissors with consequences

I wanted street-side combat to feel like more than dice. Just trading damage with an enemy round-by-round was boring within minutes. I needed a small layer of decision-making that could compound in interesting ways without becoming a full-on tactics game.

What I landed on is a three-stance system per round: attack, aim, or cover (plus flee and stim as utility verbs). The interactions are:

  • AIM > COVER: an aimed attack pierces enemy cover (cover does nothing)
  • COVER > ATTACK: taking cover blocks a non-aimed attack entirely
  • ATTACK > AIM: hitting someone while they aim disrupts their bonus

The enemy picks a stance too, and the player has to read what the enemy is going to do. The trick is that I pre-select the enemy's next stance at the end of each round so it's locked in before the player chooses. The display might say:

The Goon is taking cover.

The helpfulness of the hint is gated by an awareness roll that scales with street cred. At cred 1 you're guessing maybe 30% of the time. At cred 10 you read every stance.

AWARENESS_BASE = 0.20      # base reveal probability
AWARENESS_PER_CRED = 0.10  # +10% per cred point

def _prepare_next_round(self, active):
    next_verb = self._pick_enemy_action(active)
    cred = self.character_manager.get_character()['street_cred']
    p_reveal = min(1.0, AWARENESS_BASE + cred * AWARENESS_PER_CRED)
    revealed = random.random() < p_reveal
    self._save_state(active, next_verb=next_verb, revealed=revealed)

I'm fond of this for two reasons. First, it gives street cred a tangible mechanical effect that you can actually feel during combat. Second, the design aligns with the fiction: a green OPERATOR can't read body language; a hardened one can. The thing the stat is meant to represent in the fiction is exactly what the mechanic gives you.

There's a second layer on top of stance: crit and fatal margins. When an attack hits, the margin between the success roll and the threshold determines damage tier:

fatal_threshold = 0.85 if aim_active else 0.95
crit_threshold  = 0.60 if aim_active else 0.80

if margin > fatal_threshold:
    fatal = True
    damage = active['enemy_hp']  # one-shot kill
elif margin > crit_threshold:
    crit = True
    damage = int(round(base_damage * 2.0))
elif margin < 0.15:
    graze = True
    damage = max(1, int(round(base_damage * 0.5)))

A fatal hit ends the fight on the spot. They're rare, but they happen, and they fit the fiction - we're shooting at people with guns and one well-placed shot is sometimes all it takes. Aiming widens both the crit and fatal thresholds, so the AIM stance becomes a real risk-reward call: skip a turn of damage now, set up a possible one-shot next turn.

The interactions all stack. You aim, the enemy chooses cover (you read it), so your aimed shot pierces cover AND has a wider fatal threshold. Or you aim, the enemy chooses attack, you take a hit that disrupts your aim bonus by half. The combat loop ends up feeling reactive without ever needing more than a few keystrokes per round.

Trust gossips through factions

The fixers in this game have always had per-fixer trust scores. What they didn't have, until recently, is any awareness of each other. If you ran a clean job for one fixer, that was a private matter between the two of you. Faction adjacencies didn't matter. The world was, in effect, fourteen disconnected one-on-one relationships.

That bugged me. Trust is the kind of thing that should bleed across boundaries. So I built a faction graph. Allies and rivals are tagged on each faction config, and a small distance-weighted search propagates a fraction of every trust delta out through the graph, creating ripple effects throughout the game world:

ALLY_RATE = 0.25
RIVAL_RATE = 0.15
DECAY_PER_HOP = 0.5
MAX_DISTANCE = 3

def spillover_targets(self, source_faction, base_delta):
    # BFS from source; each edge is signed +1 (allied) or -1 (rival).
    # Multi-hop signs multiply: enemy-of-my-enemy = ally.
    visited = {source_faction: (0, 1)}
    queue = deque([(source_faction, 0, 1)])
    while queue:
        cur, dist, sign = queue.popleft()
        if dist >= MAX_DISTANCE:
            continue
        for neighbor, edge_sign in self._neighbors(cur):
            if neighbor in visited:
                continue
            visited[neighbor] = (dist + 1, sign * edge_sign)
            queue.append((neighbor, dist + 1, sign * edge_sign))

    out = []
    gossip = self.gossip_factor(source_faction)
    for fid, (dist, sign) in visited.items():
        rate = ALLY_RATE if sign > 0 else RIVAL_RATE
        decay = DECAY_PER_HOP ** max(0, dist - 1)
        out.append((fid, base_delta * sign * rate * decay * gossip))
    return out

Picture: anarchist factions are rivals of corporates, corporates are allied with military. Trust delta from your work for an anarchist fixer hits military fixers with a negative sign, decayed by one hop. The math says "the friend of my enemy distrusts me a little," and that's correct.

Each faction also has a gossip_factor between roughly 0.4 and 1.4. Media types are loud (1.4); AI-aligned factions are secretive (0.4). The same trust delta out of a media fixer ripples three times harder than out of an AI fixer. That gossip factor became one of those small numbers that ended up shaping how the world feels way more than I expected. A run with a media-aligned fixer makes you locally famous; the same run with an AI fixer might as well not have happened.

The DM voice surfaces this in the fixer's prompt addendum: both a persistent "what they've heard about you" line and a recent-events highlight from the last fictional day:

Through the faction grapevine you've heard about: runs you've done for SPINDLE (positive word); trouble around MOLOTOV (bad word).

Just heard about the OPERATOR's work around: KITE (clean), HAMMER (messy). Reference one of these naturally if it fits the tone - don't recite the list.

The "don't recite the list" line was a hard-won lesson. The first version of the prompt without it had the LLM dutifully reading off the list of every fixer's name and outcome at the start of every reply. The fix was to tell the model what not to do as much as what to do.

Storylines are chains of contracts

The contract-by-contract grind is fine, but it's just a grind. To give the player something to build toward, I added the chain system.

A chain is a multi-mission arc unlocked when the OPERATOR reaches a certain trust level with any fixer in a faction. Each step is a real contract injected into the inbox, with the chain metadata stored on the contract row so the resolver knows what to do when it completes. Branches at a step are hard - choosing one variant withdraws the other. Failure can route to a "recovery" beat (heat / eurodollar / trust cost) instead of just ending the chain.

The chain config looks like this (truncated; the full arc has three steps and a branching middle) and is essentially a graph of graphs:

{
  "chain_id": "street_relic",
  "name": "The Last Run of GHOST_PIN",
  "faction": "street",
  "fixer_pool": ["kite"],
  "unlock_trust_threshold": 0.4,
  "steps": [
    { "step": 1, "title": "GHOST_PIN's last cache",
      "contract": { /* a full mission_graph contract */ } },
    { "step": 2, "title": "What's in the box",
      "branches": [
        { "branch": "deliver", "contract": { /* respect path */ } },
        { "branch": "open",    "contract": { /* curious path */ } }
      ] },
    { "step": 3, "title": "GHOST_PIN's killer",
      "contract": { /* climax combat */ } }
  ],
  "recovery_step": {
    "title": "Lay low for a cycle",
    "cost": {"heat": 12, "eurodollars": 1500}
  },
  "rewards": [
    {"type": "daemon", "payload": {
      "daemon_type": "icebreaker", "tier": 4,
      "label": "GHOST_PIN's Icebreaker"
    }},
    {"type": "weapon", "payload": { /* unique pistol */ }},
    {"type": "directed_contract", "payload": { /* recurring premium */ }},
    {"type": "bbs_insider_board", "payload": {
      "address": "darknet://blackmarket"
    }},
    {"type": "trusted_vendor", "payload": {"shop_id": "bayside_market"}}
  ]
}

There are currently five reward types: faction-aligned daemons, recurring premium contracts, unique weapons, BBS insider board access, and trusted-vendor status at faction-aligned shops. A chain climax can grant any combination. Authoring a chain is mostly authoring contract JSONs; the framework handles the chain progression, the branch withdrawal of unchosen contracts, the recovery routing on failure, and the reward delivery on completion.

I authored the first chain end-to-end as proof. Future chains will be a mix of hand-authored climaxes and LLM-generated middle beats. This was the design call I went back and forth on the longest. A fully hand-authored chain feels like it has soul; a fully LLM-generated chain scales but loses character. The hybrid feels right. The climax is what the player remembers, so author that, and let the LLM fill in the middle with pre-tagged structural slots.

The game can actually end

The hardest design problem of the whole project was deciding how the game ends. A sandbox without a finish line is fine for a few sessions but eventually the player drifts. I wanted the game to point at something, so I came up with three distinct ending paths:

  • RETIREMENT: clean exit. Earned by high cred, low heat, multiple chain completions, no active high-tier rivals. The final chain is "The Last Run" - one big payday, a quiet handoff to a new runner, the window-shopping ending. Bittersweet.
  • FIXER ASCENSION: you become one of them. Earned by maxing trust with several fixers across multiple factions. The final chain is "The Introduction" - a council interview, an initiation pull, your first contract going out to NPC operators.
  • MEGACORP TAKEDOWN: bring one down. Earned by gathering a corp's vulnerability intel + supportive faction reputation + a successful intrusion against the corp. Currently authored against Militech; the other megacorps are framework-only.

The paths are mutually exclusive. Accepting the FINAL CHAIN of any path locks the others permanently. I didn't add an "are you sure?" confirm; the action is the commitment. There's something about being able to undo a big choice that makes the choice feel smaller. You walk into the council interview, you've chosen.

When a path's final chain completes, the game flips an ng_plus_pending flag. The player keeps their stats and codex; the world clock and contract pool reset. The next cycle is a fresh world with a slightly weathered runner.

def consume_ng_plus(self) -> bool:
    state = self.get_state()
    if not state.get('ng_plus_pending'):
        return False
    with self.conn:
        self.conn.execute(
            "UPDATE endgame_state SET "
            "  ng_plus_pending = 0, "
            "  ng_plus_count = ng_plus_count + 1, "
            "  committed_path = NULL, completed_path = NULL "
            "WHERE id = 1"
        )
    return True

Even though you are resetting to a "new" character, I wanted the footprints left by your past exploits to persist. So look for comments from the AI cores (they remember their previous OPERATOR) and BBS posts for a walk down memory lane.

What I learned

A few patterns stand out from this stretch of work:

Author-time data + runtime engine. Almost every system I'm proudest of: missions, chains, vendetta arcs (more on this in another post), faction relationships, is a thin runtime orchestrated by a piece of authored or generated JSON. The engine doesn't know about specific contracts; it just walks the graph. The engine doesn't know about specific factions; it just walks the spillover search. This pattern keeps the codebase small and the content surface huge.

The fiction is a design tool, not a coat of paint. The roll for combat awareness exists because the cred stat means something in the fiction. The handoff system exists because cores can't be loaded simultaneously. The best design decisions are the ones where the fiction and the implementation point at the same answer.

Loud failures > silent failures. I keep coming back to this. The first version of half these systems swallowed exceptions trying to be polite. Every single one I've revisited and replaced with a noisy crash. A noisy bug gets fixed in twenty minutes; a silent bug eats a week.

Now that all the major gameplay elements are in and tested, I can focus on polish and making them (hopefully) more fun to engage with. After that I'll pivot to content-authoring mode.

Still to come is a post detailing the physical build-in-progress. I'll likely revisit some of these game systems in more detail, too.

To be continued...