[{"data":1,"prerenderedAt":1292},["ShallowReactive",2],{"project-\u002Fprojects\u002Fhre-faction-system":3},{"id":4,"title":5,"body":6,"coverImage":1273,"date":1274,"description":1275,"extension":1276,"featured":102,"liveUrl":1273,"meta":1277,"navigation":102,"path":1278,"repoUrl":1279,"screenshots":1273,"seo":1280,"stack":1281,"stem":1288,"summary":1289,"visibility":1290,"__hash__":1291},"projects\u002Fprojects\u002Fhre-faction-system.md","HRE Faction System",{"type":7,"value":8,"toc":1262},"minimark",[9,28,31,34,39,52,56,203,229,236,240,379,383,397,434,456,490,504,517,542,563,576,597,647,651,664,1027,1031,1039,1043,1119,1177,1226,1230,1236,1240,1255,1258],[10,11,12,13,17,18,27],"p",{},"A modular, server-authoritative faction system for a Roblox game: a Python\u002FRust\nbackend exposing a versioned REST API, and a Luau client that Roblox game\nservers use to talk to it. Built as a commissioned, two-person project —\nbackend and platform integration by ",[14,15,16],"strong",{},"Ken",", with Roblox-side systems and UI\nby ",[14,19,20],{},[21,22,26],"a",{"href":23,"rel":24},"https:\u002F\u002Fceluxis.com",[25],"nofollow","Jason Davila",".",[10,29,30],{},"Factions in this system aren't just a group tag: single-membership\nenforcement, gamepass-gated creation, progression-based ranks with granular\npermissions, a real-money-adjacent treasury, prestige-gated leveling, and a\nlifetime leaderboard — all enforced server-side, because a client-authoritative\nversion of any of this is a dupe exploit waiting to happen.",[32,33],"hr",{},[35,36,38],"h2",{"id":37},"why-this-exists","Why this exists",[10,40,41,42,46,47,51],{},"Roblox games routinely ship faction\u002Fguild systems as client-trusted state —\na ",[43,44,45],"code",{},"RemoteEvent"," sets a value, a script on the other end believes it. That\nmodel breaks the moment a player wants to duplicate treasury funds, grant\nthemselves a rank they can't hold, or rejoin a faction they were kicked from.\nThis project moves ",[48,49,50],"em",{},"all"," of that authority off the Roblox client and into a\nbackend that Roblox game servers call over HTTPS, with every mutation\nre-validated against the database on every request. Roblox becomes a\nrendering and input layer; the backend is where the rules actually live.",[35,53,55],{"id":54},"architecture","Architecture",[57,58,63],"pre",{"className":59,"code":60,"language":61,"meta":62,"style":62},"language-mermaid shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","flowchart LR\n    subgraph Roblox[\"Roblox Game Server\"]\n        LS[\"Client LocalScripts\"] -- \"Request \u002F Updated Remotes\" --> FS[\"FactionServer.luau\\n(validation, gamepass gate, mock mode)\"]\n        FS --> Client[\"FactionsAPI.luau\\n(HTTP client, cache, idempotency, MessagingService)\"]\n    end\n\n    subgraph Backend[\"Python Backend (FastAPI)\"]\n        MW[\"Idempotency Middleware\\n(claim \u002F replay \u002F 409-pending)\"]\n        Router[\"factions APIRouter\\n(auth + permission-checked routes)\"]\n        Rust[\"_core (Rust \u002F PyO3)\\nfuzzy name matching\"]\n        MW --> Router\n        Router \u003C--> Rust\n    end\n\n    Mongo[(\"MongoDB\\nfactions \u002F users \u002F idempotency_keys\")]\n    Discord[\"Discord Webhook\\n(audit log, fire-and-forget)\"]\n    OpenCloud[\"Roblox Open Cloud\\nMessagingService\"]\n\n    Client -- \"HTTPS + X-API-Key + Idempotency-Key\" --> MW\n    Router --> Mongo\n    Router -. \"BackgroundTasks\" .-> Discord\n    OpenCloud -. \"cross-server cache\\ninvalidation push\" .-> Client\n    Backend -. \"publish on external\\nmutation\" .-> OpenCloud\n","mermaid","",[43,64,65,73,79,85,91,97,104,110,116,122,128,134,140,145,150,156,162,168,173,179,185,191,197],{"__ignoreMap":62},[66,67,70],"span",{"class":68,"line":69},"line",1,[66,71,72],{},"flowchart LR\n",[66,74,76],{"class":68,"line":75},2,[66,77,78],{},"    subgraph Roblox[\"Roblox Game Server\"]\n",[66,80,82],{"class":68,"line":81},3,[66,83,84],{},"        LS[\"Client LocalScripts\"] -- \"Request \u002F Updated Remotes\" --> FS[\"FactionServer.luau\\n(validation, gamepass gate, mock mode)\"]\n",[66,86,88],{"class":68,"line":87},4,[66,89,90],{},"        FS --> Client[\"FactionsAPI.luau\\n(HTTP client, cache, idempotency, MessagingService)\"]\n",[66,92,94],{"class":68,"line":93},5,[66,95,96],{},"    end\n",[66,98,100],{"class":68,"line":99},6,[66,101,103],{"emptyLinePlaceholder":102},true,"\n",[66,105,107],{"class":68,"line":106},7,[66,108,109],{},"    subgraph Backend[\"Python Backend (FastAPI)\"]\n",[66,111,113],{"class":68,"line":112},8,[66,114,115],{},"        MW[\"Idempotency Middleware\\n(claim \u002F replay \u002F 409-pending)\"]\n",[66,117,119],{"class":68,"line":118},9,[66,120,121],{},"        Router[\"factions APIRouter\\n(auth + permission-checked routes)\"]\n",[66,123,125],{"class":68,"line":124},10,[66,126,127],{},"        Rust[\"_core (Rust \u002F PyO3)\\nfuzzy name matching\"]\n",[66,129,131],{"class":68,"line":130},11,[66,132,133],{},"        MW --> Router\n",[66,135,137],{"class":68,"line":136},12,[66,138,139],{},"        Router \u003C--> Rust\n",[66,141,143],{"class":68,"line":142},13,[66,144,96],{},[66,146,148],{"class":68,"line":147},14,[66,149,103],{"emptyLinePlaceholder":102},[66,151,153],{"class":68,"line":152},15,[66,154,155],{},"    Mongo[(\"MongoDB\\nfactions \u002F users \u002F idempotency_keys\")]\n",[66,157,159],{"class":68,"line":158},16,[66,160,161],{},"    Discord[\"Discord Webhook\\n(audit log, fire-and-forget)\"]\n",[66,163,165],{"class":68,"line":164},17,[66,166,167],{},"    OpenCloud[\"Roblox Open Cloud\\nMessagingService\"]\n",[66,169,171],{"class":68,"line":170},18,[66,172,103],{"emptyLinePlaceholder":102},[66,174,176],{"class":68,"line":175},19,[66,177,178],{},"    Client -- \"HTTPS + X-API-Key + Idempotency-Key\" --> MW\n",[66,180,182],{"class":68,"line":181},20,[66,183,184],{},"    Router --> Mongo\n",[66,186,188],{"class":68,"line":187},21,[66,189,190],{},"    Router -. \"BackgroundTasks\" .-> Discord\n",[66,192,194],{"class":68,"line":193},22,[66,195,196],{},"    OpenCloud -. \"cross-server cache\\ninvalidation push\" .-> Client\n",[66,198,200],{"class":68,"line":199},23,[66,201,202],{},"    Backend -. \"publish on external\\nmutation\" .-> OpenCloud\n",[10,204,205,206,209,210,213,214,217,218,220,221,224,225,228],{},"The Roblox side is split in two deliberately: ",[43,207,208],{},"FactionServer.luau"," owns everything\nplayer-facing (input validation, profanity filtering, gamepass\u002Fgroup gating, a\n",[43,211,212],{},"MOCK_MODE"," in-memory backend for offline Studio testing) and never touches the\nnetwork itself; ",[43,215,216],{},"FactionsAPI.luau"," owns everything backend-facing (HTTP, caching,\nretries, idempotency keys, the MessagingService subscription) and knows nothing\nabout Roblox UI concerns. Swapping ",[43,219,212],{}," off is the only thing that changes\nwhich one actually talks to the backend — the client-facing ",[43,222,223],{},"Request","\u002F",[43,226,227],{},"Updated","\nRemote contract is identical either way.",[10,230,231,232,235],{},"The backend is distributed as an installable Python package (",[43,233,234],{},"backend",") that\nmounts into a host FastAPI app — it doesn't own the ASGI app itself, so it can\nbe embedded alongside other services (a Discord bot's API, an admin panel,\netc.) without becoming a second source of truth.",[35,237,239],{"id":238},"tech-stack","Tech stack",[241,242,243,259],"table",{},[244,245,246],"thead",{},[247,248,249,253,256],"tr",{},[250,251,252],"th",{},"Layer",[250,254,255],{},"Choice",[250,257,258],{},"Why",[260,261,262,278,292,307,321,339,350,365],"tbody",{},[247,263,264,268,275],{},[265,266,267],"td",{},"API framework",[265,269,270,271,274],{},"FastAPI (",[43,272,273],{},"APIRouter",")",[265,276,277],{},"Async-native, Pydantic-integrated validation, mountable into a host app",[247,279,280,283,289],{},[265,281,282],{},"Database",[265,284,285,286],{},"MongoDB via ",[14,287,288],{},"PyMongo Async",[265,290,291],{},"Document model fits nested faction\u002Frank\u002Fpermission data; async driver, not Motor",[247,293,294,297,300],{},[265,295,296],{},"Validation \u002F models",[265,298,299],{},"Pydantic v2",[265,301,302,303,306],{},"Aliasing to Mongo's ",[43,304,305],{},"_id",", structural validation on every request body",[247,308,309,312,318],{},[265,310,311],{},"CPU-bound logic",[265,313,314,315,274],{},"Rust via PyO3 (",[43,316,317],{},"backend._core",[265,319,320],{},"Fuzzy faction\u002Frank name matching (Levenshtein) scanned across every existing name — cheap in Rust, wasteful per-call over FFI otherwise",[247,322,323,326,336],{},[265,324,325],{},"Build",[265,327,328,331,332,335],{},[43,329,330],{},"uv"," + ",[43,333,334],{},"maturin"," (abi3-py39)",[265,337,338],{},"Single Python-version-independent wheel for the Rust extension",[247,340,341,344,347],{},[265,342,343],{},"Roblox link",[265,345,346],{},"Roblox Open Cloud (MessagingService) + a hand-written HTTP\u002FLuau client",[265,348,349],{},"Bidirectional sync without polling",[247,351,352,355,362],{},[265,353,354],{},"Idempotency",[265,356,357,358,361],{},"Starlette ",[43,359,360],{},"BaseHTTPMiddleware",", atomic claim → execute → store",[265,363,364],{},"Mounted once at the app level (same \"can't forget it\" precedent as the API-key dependency) instead of repeated per route",[247,366,367,370,376],{},[265,368,369],{},"Audit logging",[265,371,372,373],{},"Discord webhooks, fire-and-forget via ",[43,374,375],{},"BackgroundTasks",[265,377,378],{},"Never on the request's critical path — a Discord outage can't fail a faction mutation",[35,380,382],{"id":381},"engineering-decisions-worth-calling-out","Engineering decisions worth calling out",[10,384,385,388,389,392,393,396],{},[14,386,387],{},"Every mutating endpoint follows the same authorization chain, no exceptions:","\nresolve the faction (404 if missing) → resolve the caller's membership ",[48,390,391],{},"in that\nfaction"," (403 if not a member) → check the specific permission flag the action\nrequires (not \"is owner\", except where disbandment\u002Fownership genuinely is\nowner-only) → only then write. Rank self-escalation is blocked explicitly: a\n",[43,394,395],{},"manage_ranks"," holder can't assign or edit a rank at or above their own level.",[57,398,402],{"className":399,"code":400,"language":401,"meta":62,"style":62},"language-python shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","faction = await _get_faction_or_404(factions_db, faction_id)\ninitiator_rank = await require_faction_permission(\n    users_db, faction, kick_data.initiator_id, \"manage_members\"\n)\nif target_rank and target_rank.level >= initiator_rank.level:\n    raise HTTPException(403, \"Cannot kick a member with an equal or higher rank.\")\n","python",[43,403,404,409,414,419,424,429],{"__ignoreMap":62},[66,405,406],{"class":68,"line":69},[66,407,408],{},"faction = await _get_faction_or_404(factions_db, faction_id)\n",[66,410,411],{"class":68,"line":75},[66,412,413],{},"initiator_rank = await require_faction_permission(\n",[66,415,416],{"class":68,"line":81},[66,417,418],{},"    users_db, faction, kick_data.initiator_id, \"manage_members\"\n",[66,420,421],{"class":68,"line":87},[66,422,423],{},")\n",[66,425,426],{"class":68,"line":93},[66,427,428],{},"if target_rank and target_rank.level >= initiator_rank.level:\n",[66,430,431],{"class":68,"line":99},[66,432,433],{},"    raise HTTPException(403, \"Cannot kick a member with an equal or higher rank.\")\n",[10,435,436,439,440,443,444,447,448,451,452,455],{},[14,437,438],{},"Concurrency is handled at the data layer, not in application logic.","\nTreasury withdrawals guard the balance check in the ",[48,441,442],{},"same"," atomic\n",[43,445,446],{},"update_one"," as the decrement (",[43,449,450],{},"$gte"," filter + ",[43,453,454],{},"$inc","), never a separate\nread-then-write — which would be a race under concurrent requests. Faction\nname uniqueness and join-code uniqueness are backed by real MongoDB unique\nindexes; the fuzzy-similarity pre-check is a friendly error message, not the\nactual guarantee.",[57,457,459],{"className":399,"code":458,"language":401,"meta":62,"style":62},"result = await factions_db.update_one(\n    {\"_id\": faction_id, \"treasury.gold\": {\"$gte\": amount}},\n    {\"$inc\": {\"treasury.gold\": -amount}},\n)\nif result.matched_count == 0:\n    raise HTTPException(400, \"Insufficient treasury funds for this withdrawal.\")\n",[43,460,461,466,471,476,480,485],{"__ignoreMap":62},[66,462,463],{"class":68,"line":69},[66,464,465],{},"result = await factions_db.update_one(\n",[66,467,468],{"class":68,"line":75},[66,469,470],{},"    {\"_id\": faction_id, \"treasury.gold\": {\"$gte\": amount}},\n",[66,472,473],{"class":68,"line":81},[66,474,475],{},"    {\"$inc\": {\"treasury.gold\": -amount}},\n",[66,477,478],{"class":68,"line":87},[66,479,423],{},[66,481,482],{"class":68,"line":93},[66,483,484],{},"if result.matched_count == 0:\n",[66,486,487],{"class":68,"line":99},[66,488,489],{},"    raise HTTPException(400, \"Insufficient treasury funds for this withdrawal.\")\n",[10,491,492,495,496,499,500,503],{},[14,493,494],{},"The Rust boundary is drawn deliberately narrow."," I\u002FO — database calls,\nwebhook sends, Roblox Open Cloud requests — stays in async Python, since Rust\ndoesn't help I\u002FO-bound work and just adds a maintenance surface. The only\n",[43,497,498],{},"_core"," functions are the CPU-bound scan over every existing faction\u002Frank name\non a rename or creation, batched into a single FFI call instead of one\n",[43,501,502],{},"is_similar()"," round-trip per candidate.",[10,505,506,509,510,512,513,516],{},[14,507,508],{},"Renown drives level, title, and member capacity — and none of those three are\nstored."," Renown itself is award-only (",[43,511,454],{},"-only, rejects non-positive amounts,\nno endpoint ever sets it directly), and level\u002Ftitle\u002Fcapacity are derived fresh\nfrom it on every read by walking a fixed ladder — the same pattern the\nexisting manual prestige\u002Ftitle system already used, just never persisted this\ntime, so there's nothing for the two to drift out of sync on. A faction's\ncustom-rank system stays fully independent of this: renown gates ",[48,514,515],{},"how many","\nmembers a faction can hold, not what ranks it can have.",[10,518,519,522,523,526,527,530,531,534,535,538,539,27],{},[14,520,521],{},"Idempotency is a claim, not just a cache."," Every mutating request requires\nan ",[43,524,525],{},"Idempotency-Key"," header; the middleware's first move is an atomic\n",[43,528,529],{},"insert_one"," guarded by a unique index on the key, so a client's own retry\nlanding ",[48,532,533],{},"while the first attempt is still in flight"," gets a ",[43,536,537],{},"409"," instead of\nre-running the handler — replaying a stored response only covers the case\nwhere the first attempt already finished. Without the claim step, idempotency\nwould only protect against retries-after-failure, not retries-after-slow,\nwhich is the case that actually matters for something like ",[43,540,541],{},"AwardRenown",[10,543,544,547,548,550,551,554,555,558,559,562],{},[14,545,546],{},"Join-code visibility is enforced by what the response model contains, not\nby a per-route viewer check."," ",[43,549,43],{}," is excluded from every faction-returning\nroute's response model by default (",[43,552,553],{},"response_model_exclude={\"code\"}","); only\n",[43,556,557],{},"create"," and ",[43,560,561],{},"refresh-code"," — where the caller is the owner by construction —\nand the complete-state endpoint (which already knows who's asking) ever\ninclude it. No route has to remember to check \"is this caller the owner\" just\nto decide whether to redact a field.",[10,564,565,568,569,571,572,575],{},[14,566,567],{},"The Luau client mirrors the API's shape, not just its endpoints."," Every\nfunction in ",[43,570,216],{}," matches a route's exact payload and error\nsemantics (404\u002F403\u002F409\u002F400 surfaced as a typed ",[43,573,574],{},"ApiError.code",", not a raw\nstatus number), plus:",[577,578,579,583,590],"ul",{},[580,581,582],"li",{},"TTL-based response caching with in-flight request coalescing, so N\nsimultaneous callers on the same server collapse into one HTTP call instead\nof N.",[580,584,585,586,589],{},"A ",[43,587,588],{},"MessagingService"," subscription so the backend can push cache invalidation\nto every live Roblox server the moment a faction changes through a path\nthat didn't originate from Roblox (e.g. a Discord bot command) — otherwise\na server would keep serving a stale faction until its TTL expired.",[580,591,592,593,596],{},"The API key loaded via ",[43,594,595],{},"HttpService:GetSecret",", never a literal in source.",[57,598,602],{"className":599,"code":600,"language":601,"meta":62,"style":62},"language-lua shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","local ok, faction = FactionsAPI.GetFactionById(factionId) -- cached, coalesced\nif not ok then\n    if faction.code == \"not_found\" then\n        -- handle a faction that no longer exists\n    end\n    return\nend\n\nlocal ok2, updated = FactionsAPI.KickMember(factionId, initiatorId, targetId)\n","lua",[43,603,604,609,614,619,624,628,633,638,642],{"__ignoreMap":62},[66,605,606],{"class":68,"line":69},[66,607,608],{},"local ok, faction = FactionsAPI.GetFactionById(factionId) -- cached, coalesced\n",[66,610,611],{"class":68,"line":75},[66,612,613],{},"if not ok then\n",[66,615,616],{"class":68,"line":81},[66,617,618],{},"    if faction.code == \"not_found\" then\n",[66,620,621],{"class":68,"line":87},[66,622,623],{},"        -- handle a faction that no longer exists\n",[66,625,626],{"class":68,"line":93},[66,627,96],{},[66,629,630],{"class":68,"line":99},[66,631,632],{},"    return\n",[66,634,635],{"class":68,"line":106},[66,636,637],{},"end\n",[66,639,640],{"class":68,"line":112},[66,641,103],{"emptyLinePlaceholder":102},[66,643,644],{"class":68,"line":118},[66,645,646],{},"local ok2, updated = FactionsAPI.KickMember(factionId, initiatorId, targetId)\n",[35,648,650],{"id":649},"api-surface","API surface",[10,652,653,654,657,658,660,661,663],{},"All routes sit behind a shared-secret ",[43,655,656],{},"X-API-Key"," header (or Roblox Open Cloud\nsignature verification), independent of the per-faction permission checks\nbelow. Every mutating route additionally requires an ",[43,659,525],{}," header.\n",[43,662,43],{}," (the join code) is excluded from every response except where marked —\nsee \"Join-code visibility is enforced by what the response model contains\"\nabove.",[241,665,666,682],{},[244,667,668],{},[247,669,670,673,676,679],{},[250,671,672],{},"Method",[250,674,675],{},"Route",[250,677,678],{},"Auth beyond API key",[250,680,681],{},"Notes",[260,683,684,702,718,738,754,773,795,817,836,853,870,886,905,927,945,963,982,1005],{},[247,685,686,691,696,699],{},[265,687,688],{},[43,689,690],{},"GET",[265,692,693],{},[43,694,695],{},"\u002Ffactions\u002F{id}",[265,697,698],{},"—",[265,700,701],{},"public read",[247,703,704,708,713,715],{},[265,705,706],{},[43,707,690],{},[265,709,710],{},[43,711,712],{},"\u002Ffactions\u002Fname\u002F{name}",[265,714,698],{},[265,716,717],{},"case-insensitive",[247,719,720,724,729,731],{},[265,721,722],{},[43,723,690],{},[265,725,726],{},[43,727,728],{},"\u002Ffactions\u002F{id}\u002Fmembers",[265,730,698],{},[265,732,733,734,737],{},"resolves rank from ",[43,735,736],{},"custom_ranks",", never a stored snapshot",[247,739,740,744,749,751],{},[265,741,742],{},[43,743,690],{},[265,745,746],{},[43,747,748],{},"\u002Ffactions\u002Fplayer\u002F{roblox_id}",[265,750,698],{},[265,752,753],{},"reverse lookup: a player's current faction",[247,755,756,760,765,767],{},[265,757,758],{},[43,759,690],{},[265,761,762],{},[43,763,764],{},"\u002Ffactions\u002Fplayer\u002F{roblox_id}\u002Fstate",[265,766,698],{},[265,768,769,770,772],{},"faction + this player's membership + members + ranks in one call; includes ",[43,771,43],{}," only if this player is the owner",[247,774,775,779,784,786],{},[265,776,777],{},[43,778,690],{},[265,780,781],{},[43,782,783],{},"\u002Ffactions\u002Fleaderboard",[265,785,698],{},[265,787,788,789,224,792,274],{},"sorted by renown, paginated (",[43,790,791],{},"limit",[43,793,794],{},"skip",[247,796,797,802,807,809],{},[265,798,799],{},[43,800,801],{},"POST",[265,803,804],{},[43,805,806],{},"\u002Ffactions\u002Fcreate",[265,808,698],{},[265,810,811,812],{},"atomic gamepass-or-credit check, unwinds fully on any downstream failure; ",[14,813,814,815],{},"includes ",[43,816,43],{},[247,818,819,824,829,834],{},[265,820,821],{},[43,822,823],{},"PATCH",[265,825,826],{},[43,827,828],{},"\u002Ffactions\u002F{id}\u002Fupdate",[265,830,831],{},[43,832,833],{},"manage_faction",[265,835],{},[247,837,838,843,847,850],{},[265,839,840],{},[43,841,842],{},"DELETE",[265,844,845],{},[43,846,695],{},[265,848,849],{},"owner-only",[265,851,852],{},"disbands, clears every member's membership",[247,854,855,859,864,867],{},[265,856,857],{},[43,858,801],{},[265,860,861],{},[43,862,863],{},"\u002Ffactions\u002F{id}\u002Fjoin",[265,865,866],{},"join-code match",[265,868,869],{},"atomic capacity guard (renown-derived), unwinds on a lost race",[247,871,872,876,881,883],{},[265,873,874],{},[43,875,801],{},[265,877,878],{},[43,879,880],{},"\u002Ffactions\u002F{id}\u002Fleave",[265,882,698],{},[265,884,885],{},"owner leaving disbands the faction",[247,887,888,892,897,902],{},[265,889,890],{},[43,891,801],{},[265,893,894],{},[43,895,896],{},"\u002Ffactions\u002F{id}\u002Fkick",[265,898,899],{},[43,900,901],{},"manage_members",[265,903,904],{},"can't kick an equal-or-higher rank",[247,906,907,911,916,920],{},[265,908,909],{},[43,910,801],{},[265,912,913],{},[43,914,915],{},"\u002Ffactions\u002F{id}\u002Frefresh-code",[265,917,918],{},[43,919,833],{},[265,921,922,926],{},[14,923,814,924],{},[43,925,43],{},"; old code invalid immediately",[247,928,929,933,938,942],{},[265,930,931],{},[43,932,823],{},[265,934,935],{},[43,936,937],{},"\u002Ffactions\u002F{id}\u002Fmembers\u002F{id}\u002Frank",[265,939,940],{},[43,941,395],{},[265,943,944],{},"self-escalation blocked",[247,946,947,951,956,960],{},[265,948,949],{},[43,950,823],{},[265,952,953],{},[43,954,955],{},"\u002Ffactions\u002F{id}\u002Fupgrade",[265,957,958],{},[43,959,833],{},[265,961,962],{},"prestige-gated, 12 named tiers (Initiate → The Nexus) — manual, independent of renown",[247,964,965,969,974,977],{},[265,966,967],{},[43,968,801],{},[265,970,971],{},[43,972,973],{},"\u002Ffactions\u002F{id}\u002Frenown",[265,975,976],{},"server-only",[265,978,979,981],{},[43,980,454],{},"-only, no permission check — the one documented exception to the auth chain",[247,983,984,988,997,1002],{},[265,985,986],{},[43,987,801],{},[265,989,990,993,994],{},[43,991,992],{},"\u002Ffactions\u002F{id}\u002Ftreasury\u002Fdeposit"," | ",[43,995,996],{},"withdraw",[265,998,999],{},[43,1000,1001],{},"manage_treasury",[265,1003,1004],{},"atomic balance guard",[247,1006,1007,1015,1020,1024],{},[265,1008,1009,224,1011,224,1013],{},[43,1010,801],{},[43,1012,823],{},[43,1014,842],{},[265,1016,1017],{},[43,1018,1019],{},"\u002Ffactions\u002F{id}\u002Franks[\u002F{id}]",[265,1021,1022],{},[43,1023,395],{},[265,1025,1026],{},"id-keyed, capped per faction, hierarchy-checked",[35,1028,1030],{"id":1029},"project-structure","Project structure",[57,1032,1037],{"className":1033,"code":1035,"language":1036},[1034],"language-text","HREFactionSystem\u002F\n├── backend\u002F                      # Python package + Rust extension\n│   ├── src\u002Fbackend\u002F\n│   │   ├── routers\u002Ffactions.py   # the APIRouter — all HTTP-facing logic\n│   │   ├── models\u002Ffactions.py    # Pydantic v2 request\u002Fresponse models\n│   │   ├── security.py           # auth dependency + permission resolver\n│   │   ├── db.py, config.py, utils.py, types.py\n│   │   └── _core (compiled)      # from src\u002Flib.rs via maturin\n│   ├── src\u002Flib.rs                # Rust: fuzzy name matching\n│   ├── Cargo.toml\n│   └── pyproject.toml\n└── ServerScriptService\u002F\n    ├── FactionsAPI.luau          # backend client: HTTP, cache, idempotency, MessagingService\n    └── FactionServer.luau        # player-facing Remotes: validation, gamepass gate, mock mode\n","text",[43,1038,1035],{"__ignoreMap":62},[35,1040,1042],{"id":1041},"running-it-locally","Running it locally",[57,1044,1048],{"className":1045,"code":1046,"language":1047,"meta":62,"style":62},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","# Backend\ncd backend\nuv sync\nuv run maturin develop          # builds the Rust extension into the venv\ncp .env.example .env            # HREF_MONGO_URI, HREF_API_SHARED_SECRET, HREF_DISCORD_WEBHOOK_URL\nuv run uvicorn your_host_app:app --reload\n","bash",[43,1049,1050,1056,1066,1074,1090,1104],{"__ignoreMap":62},[66,1051,1052],{"class":68,"line":69},[66,1053,1055],{"class":1054},"sHwdD","# Backend\n",[66,1057,1058,1062],{"class":68,"line":75},[66,1059,1061],{"class":1060},"s2Zo4","cd",[66,1063,1065],{"class":1064},"sfazB"," backend\n",[66,1067,1068,1071],{"class":68,"line":81},[66,1069,330],{"class":1070},"sBMFI",[66,1072,1073],{"class":1064}," sync\n",[66,1075,1076,1078,1081,1084,1087],{"class":68,"line":87},[66,1077,330],{"class":1070},[66,1079,1080],{"class":1064}," run",[66,1082,1083],{"class":1064}," maturin",[66,1085,1086],{"class":1064}," develop",[66,1088,1089],{"class":1054},"          # builds the Rust extension into the venv\n",[66,1091,1092,1095,1098,1101],{"class":68,"line":93},[66,1093,1094],{"class":1070},"cp",[66,1096,1097],{"class":1064}," .env.example",[66,1099,1100],{"class":1064}," .env",[66,1102,1103],{"class":1054},"            # HREF_MONGO_URI, HREF_API_SHARED_SECRET, HREF_DISCORD_WEBHOOK_URL\n",[66,1105,1106,1108,1110,1113,1116],{"class":68,"line":99},[66,1107,330],{"class":1070},[66,1109,1080],{"class":1064},[66,1111,1112],{"class":1064}," uvicorn",[66,1114,1115],{"class":1064}," your_host_app:app",[66,1117,1118],{"class":1064}," --reload\n",[57,1120,1122],{"className":399,"code":1121,"language":401,"meta":62,"style":62},"# Host app: mount the router and opt into idempotency + startup indexes\nfrom backend.routers.factions import router as factions_router\nfrom backend.idempotency import IdempotencyMiddleware\nfrom backend.db import ensure_indexes\n\napp.include_router(factions_router)\napp.add_middleware(IdempotencyMiddleware)\n\n@app.on_event(\"startup\")\nasync def _startup():\n    await ensure_indexes()\n",[43,1123,1124,1129,1134,1139,1144,1148,1153,1158,1162,1167,1172],{"__ignoreMap":62},[66,1125,1126],{"class":68,"line":69},[66,1127,1128],{},"# Host app: mount the router and opt into idempotency + startup indexes\n",[66,1130,1131],{"class":68,"line":75},[66,1132,1133],{},"from backend.routers.factions import router as factions_router\n",[66,1135,1136],{"class":68,"line":81},[66,1137,1138],{},"from backend.idempotency import IdempotencyMiddleware\n",[66,1140,1141],{"class":68,"line":87},[66,1142,1143],{},"from backend.db import ensure_indexes\n",[66,1145,1146],{"class":68,"line":93},[66,1147,103],{"emptyLinePlaceholder":102},[66,1149,1150],{"class":68,"line":99},[66,1151,1152],{},"app.include_router(factions_router)\n",[66,1154,1155],{"class":68,"line":106},[66,1156,1157],{},"app.add_middleware(IdempotencyMiddleware)\n",[66,1159,1160],{"class":68,"line":112},[66,1161,103],{"emptyLinePlaceholder":102},[66,1163,1164],{"class":68,"line":118},[66,1165,1166],{},"@app.on_event(\"startup\")\n",[66,1168,1169],{"class":68,"line":124},[66,1170,1171],{},"async def _startup():\n",[66,1173,1174],{"class":68,"line":130},[66,1175,1176],{},"    await ensure_indexes()\n",[57,1178,1180],{"className":599,"code":1179,"language":601,"meta":62,"style":62},"-- Roblox: paste both ServerScriptService\u002FFactionsAPI.luau and FactionServer.luau,\n-- then before first use:\nlocal FactionsAPI = require(game.ServerScriptService.FactionsAPI)\nFactionsAPI.Config.BaseUrl = \"https:\u002F\u002Fyour-deployed-backend.example.com\"\nFactionsAPI.Config.ApiKeySecretName = \"HREF_API_SHARED_SECRET\" -- a Studio\u002Fpublished secret, matching the backend's key\n\n-- FactionServer.luau's own CONFIG.MOCK_MODE defaults to true (an in-memory\n-- fake backend for offline Studio testing, no network calls at all) - flip\n-- it to false once the backend above is actually reachable.\n",[43,1181,1182,1187,1192,1197,1202,1207,1211,1216,1221],{"__ignoreMap":62},[66,1183,1184],{"class":68,"line":69},[66,1185,1186],{},"-- Roblox: paste both ServerScriptService\u002FFactionsAPI.luau and FactionServer.luau,\n",[66,1188,1189],{"class":68,"line":75},[66,1190,1191],{},"-- then before first use:\n",[66,1193,1194],{"class":68,"line":81},[66,1195,1196],{},"local FactionsAPI = require(game.ServerScriptService.FactionsAPI)\n",[66,1198,1199],{"class":68,"line":87},[66,1200,1201],{},"FactionsAPI.Config.BaseUrl = \"https:\u002F\u002Fyour-deployed-backend.example.com\"\n",[66,1203,1204],{"class":68,"line":93},[66,1205,1206],{},"FactionsAPI.Config.ApiKeySecretName = \"HREF_API_SHARED_SECRET\" -- a Studio\u002Fpublished secret, matching the backend's key\n",[66,1208,1209],{"class":68,"line":99},[66,1210,103],{"emptyLinePlaceholder":102},[66,1212,1213],{"class":68,"line":106},[66,1214,1215],{},"-- FactionServer.luau's own CONFIG.MOCK_MODE defaults to true (an in-memory\n",[66,1217,1218],{"class":68,"line":112},[66,1219,1220],{},"-- fake backend for offline Studio testing, no network calls at all) - flip\n",[66,1222,1223],{"class":68,"line":118},[66,1224,1225],{},"-- it to false once the backend above is actually reachable.\n",[35,1227,1229],{"id":1228},"scope","Scope",[10,1231,1232,1235],{},[14,1233,1234],{},"In scope:"," single-faction membership, gamepass\u002Fcredit-gated creation,\nrefreshable join codes, predefined + custom progression ranks with granular\npermissions, permanent renown → finite named levels, lifetime leaderboard,\nowner-only faction management, custom cosmetic slots (banners, logos,\narmors), and exploit-resistant server-authoritative enforcement on every\nmutating endpoint.",[35,1237,1239],{"id":1238},"credits","Credits",[577,1241,1242,1247],{},[580,1243,1244,1246],{},[14,1245,16],{}," — backend architecture, API design, Rust extension, Roblox-side integrations\u002Fsystems",[580,1248,1249,1254],{},[14,1250,1251],{},[21,1252,26],{"href":23,"rel":1253},[25]," — Roblox-side systems, UI, and\nin-game integration.",[10,1256,1257],{},"This repository is shared as a portfolio reference for a commercial,\ncommissioned project; production configuration, secrets, and the host game's\ncodebase are intentionally excluded.",[1259,1260,1261],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":62,"searchDepth":75,"depth":75,"links":1263},[1264,1265,1266,1267,1268,1269,1270,1271,1272],{"id":37,"depth":75,"text":38},{"id":54,"depth":75,"text":55},{"id":238,"depth":75,"text":239},{"id":381,"depth":75,"text":382},{"id":649,"depth":75,"text":650},{"id":1029,"depth":75,"text":1030},{"id":1041,"depth":75,"text":1042},{"id":1228,"depth":75,"text":1229},{"id":1238,"depth":75,"text":1239},null,"2026-09-10","A modular, server-authoritative faction system for a Roblox game: a Python\u002FRust\nbackend exposing a versioned REST API, and a Luau client that Roblox game\nservers use to talk to it. Built as a commissioned, two-person project —\nbackend and platform integration by Ken, with Roblox-side systems and UI\nby Jason Davila.","md",{},"\u002Fprojects\u002Fhre-faction-system","https:\u002F\u002Fgithub.com\u002Fkenthegreat8\u002FHREFactionSystem",{"title":5,"description":1275},[1282,1283,1284,1285,1286,1287],"Python","FastAPI","MongoDB (PyMongo Async)","Rust","Luau","Roblox API","projects\u002Fhre-faction-system","A modular, server-authoritative faction system for a Roblox game — a Python\u002FRust backend exposing a versioned REST API, paired with a Luau client, enforcing every faction rule server-side to close off client-authoritative dupe exploits.","private","Xi4GjjZ_01_hmZhAojAG94Dqr_qPQPaDuY_b0irhVXA",1789093765157]