Grim Syndicate
A dystopian sci-fi text RPG where every choice echoes through the grid.
Grim Syndicate is the cyberpunk instance of the XRPG Engine — same ruleset, same passkey auth, same persistent-world AI narration, but the world is the Grid: factions, runners, contracts, and consequences that follow you across sessions. Operators carve out reputation by whose toes they step on. The dice are deterministic; the city remembers.
- Python
- FastAPI
- PostgreSQL
- SQLAlchemy
- Jinja2
- WebAuthn
- OpenRouter
- Apache
- systemd
Grim Syndicate is the cyberpunk-flavored instance of the XRPG Engine — same Python/FastAPI codebase that ships Tulpa, but the world is a sprawling surveillance city where you run contracts, build a reputation, and answer to whichever faction owns whatever block you happen to sleep on. Every choice echoes through the grid; campaigns persist across logins; the deterministic ruleset lives underneath the AI narration.
What's a "syndicate"
The setting is structural. There is no single benevolent state — just nested syndicates of varying ambition, all running on the same fragile substrate. Players are operators-for-hire who pick sides one job at a time. Reputation is real, faction memory is persistent, and the engine's NPC + scenario layer carries consequences forward across sessions. Burn the wrong fixer and six weeks later their cousin won't sell you a battery.
Same engine, different rules on top
- Engine layer (shared with Tulpa): passkey auth, character creation, energy economy, deterministic rolls, persistent world memory, PvP arena, encrypted per-instance config in the database. Read the engine write-up on XRPG Engine.
- Game layer (Syndicate-specific): faction reputation, contract economy, Grid-themed locations and NPCs, cyberpunk gear/affix tables, narration tuning that leans noir. None of that lives in the engine — it's all config and content stacked on top of the same Python skeleton.
Multi-instance is the whole story
Tulpa and Grim Syndicate share a git repository. They differ in
a per-deployment .instance file, a database, a
domain, and the brand assets in /static/uploads.
That's it. Adding another world is a config-and-art exercise.
The reason this exhibit exists alongside Tulpa is to show what
the engine looks like when the same skeleton is dressed for a
different genre — the game design surface area is enormous, but
the technical surface area is the engine.
Straight from the source
The project's own README.
Rendered in place — every link, image, and code block carried over from the repo. The page below is what a contributor would see opening the project for the first time.
Enemy & Encounter System — Design Document
Overview
Enemies are NPCs that players can fight, flee from, or negotiate with. They should feel like part of the world — tied to specific locations, scaled to the campaign's difficulty, and capable of dropping loot when defeated. The system builds on existing infrastructure (NPC model, fabricator, locations, scenarios) rather than introducing new tables.
What Already Exists
NPC Model (app/models/campaign.py)
is_enemyflag distinguishes hostile NPCs- Full stat block (STR, DEX, CON, INT, WIS, CHA) +
level stat_rangeJSONB for level-scaled stat generationlocation_id— ties NPC to a specific locationloot_tableJSONB — list of potential dropsgroupsJSONB — faction/species tags (e.g.,["goblin", "cave_dweller"])equipmentJSONB — what the NPC is wielding (informs combat descriptions)disposition+personality_traits— how they behave
Location Model
typefield (town, dungeon, wilderness, etc.)connectionsto other locations- NPCs are associated via
location_id
Scenario Model
type: "encounter"— one-time encounters tied to locationstrigger_conditionsJSONB — when the encounter firesrewardsJSONB — what the player gets
Fabricator Service (app/services/fabricator.py)
- LLM-based item generation already works
- Same pattern can generate enemy NPCs on the fly
Engine (app/services/engine.py)
- Already extracts intent (fight_melee, fight_ranged, etc.)
- Already parses
[ITEM_FOUND: ...]tags from narrator - Stat gains already tied to combat intents
Proposed Architecture
1. Enemy Encounter Flow
Player enters area → Engine checks location NPCs → Injects enemy context into prompt
↓
Player attacks → Intent = fight_* → Engine resolves combat outcome
↓
Narrator describes result → [ENEMY_DEFEATED: NPC Name] tag if won
↓
Engine processes tag → Rolls loot from NPC's loot_table → Grants items
2. Enemy Context Injection
When building the system prompt, the engine should load enemy NPCs associated with the player's current location and include them:
--- ENEMIES IN AREA ---
Goblin Scout (Level 2): STR 11, DEX 13, CON 10. Carries a crude dagger. Lurks near the entrance.
Cave Spider (Level 1): STR 8, DEX 14, CON 7. Venomous bite. Hangs from ceiling webs.
This gives the narrator awareness of what threats exist so it can introduce them naturally rather than making them up inconsistently.
Key file: app/services/engine.py → _build_system_prompt()
3. Combat Resolution
Combat should NOT be fully automated — the narrator still drives the story. But the engine should provide outcome hints based on stat comparison:
def _calculate_combat_advantage(character, enemy_npc):
"""Compare character stats to enemy. Returns advantage score (-1 to 1)."""
# Weight stats by intent: fight_melee weights STR/CON, fight_ranged weights DEX, etc.
# Factor in equipment bonuses
# Factor in level difference
# Add randomness (fate roll)
# Return: positive = player favored, negative = enemy favored
This advantage score gets injected into the prompt as a hint:
[COMBAT HINT: Player has moderate advantage against Goblin Scout (stat edge + equipment)]
The narrator uses this to inform whether the player succeeds, partially succeeds, or fails — without dictating exact mechanics.
Key file: New function in app/services/engine.py
4. Enemy Defeat & Loot
New tag format for the narrator:
[ENEMY_DEFEATED: Goblin Scout]
Engine processing:
- Parse
[ENEMY_DEFEATED: ...]tags (similar to_parse_item_tags) - Look up the NPC by name in the current location
- Roll against the NPC's
loot_table - Use fabricator to generate any dropped items
- Award XP bonus based on enemy level
5. Loot Table Format
The NPC's loot_table JSONB:
[
{"type": "weapon", "rarity": "common", "chance": 0.3, "context": "crude goblin dagger"},
{"type": "consumable", "rarity": "common", "chance": 0.5, "context": "small health potion"},
{"type": "misc", "rarity": "uncommon", "chance": 0.1, "context": "goblin ear trophy"}
]
Each entry has a chance (0-1 probability) and a context string passed to the fabricator for thematic item generation.
6. Enemy Fabrication
For areas without pre-built enemy NPCs, the fabricator can generate them:
async def fabricate_enemy(db, location, campaign, player_level):
"""Generate a thematic enemy NPC for a location."""
# LLM prompt includes location type, campaign setting, player level
# Returns NPC data: name, description, stats, loot_table, groups
# Saved as NPC with is_fabricated=True, is_enemy=True
This parallels fabricate_item() but for NPCs. The fabricated enemy persists in the database so future players at the same location encounter the same creature.
7. Location-Enemy Association Rules
- Dungeons: Higher enemy density, tougher enemies deeper in
- Wilderness: Patrol encounters, wildlife scaled to area level
- Towns: No random enemies (guards, thieves only via scenarios)
- Boss rooms: Single powerful enemy tied to a scenario
Enemy groups help cluster related creatures:
- All NPCs with
groups: ["goblin"]in a dungeon might appear together - Location type influences which groups spawn there
8. Enemy Scaling
Enemies should scale relative to the campaign and location, not the player:
- Campaign
min_level/max_levelsets the band - Location depth (distance from start) increases difficulty
- NPC
stat_rangeallows variance:{"STR": [10, 14], "DEX": [12, 16]} - Fabricated enemies use player level as a baseline but don't rubber-band
9. Death & Defeat
When a player loses a fight:
- Energy drops to 0 (already tracked)
- Character could be marked
is_alive = Falsefor permadeath campaigns - Or session gets a "defeated" state allowing retreat/recovery
- Gold loss as a penalty (percentage-based)
Implementation Priority
Phase 1 — Enemy Context (next)
- Load location NPCs where
is_enemy=Truein_build_system_prompt - Include enemy stats, equipment, and descriptions in narrator context
- No mechanical combat resolution yet — narrator handles it narratively
Phase 2 — Combat Hints
- Add
_calculate_combat_advantage()to engine - Inject advantage hints into prompt for fight intents
- Parse
[ENEMY_DEFEATED: ...]tags
Phase 3 — Loot & XP
- Roll NPC loot tables on defeat
- Fabricate dropped items
- Award XP bonus for combat victories
- Track defeated enemies per session
Phase 4 — Enemy Fabrication
- Add
fabricate_enemy()to fabricator service - Auto-generate enemies for locations that have none
- Factory UI for manually creating enemy NPCs (already possible via NPC creator)
Phase 5 — Advanced Combat
- Turn-based resolution with stat comparison
- Equipment modifier application
- Spell effects in combat
- Companion assistance
- Death/defeat mechanics
Key Files
| File | Role |
|---|---|
app/models/campaign.py |
NPC model (enemy stats, loot_table, location_id, groups) |
app/services/engine.py |
System prompt, intent extraction, combat resolution |
app/services/fabricator.py |
Item fabrication (extend for enemy fabrication) |
app/api/game.py |
Session management, message handling |
app/templates/pages/play_session.html |
Combat UI, enemy notifications |
Open Questions
- Should enemies respawn? Or once defeated, gone for that session?
- How does multiplayer affect encounters (shared enemies)?
- Should the narrator be given explicit HP for enemies, or keep it narrative?
- How granular should combat turns be (per-message vs multi-round)?
- Should some enemies be avoidable through non-combat means (stealth, persuasion)?
Build something like this
Want a tool like this for your shop?
We've shipped this kind of thing before. Twenty-minute intro call, no slides.