LootRNG is a free browser game with procedural loot and card strategy Clash
(CPU or async PvP). Forge weapons, manage inventory, complete quests, and climb leaderboards.
Built as a Flask monolith with modular vanilla JavaScript and PostgreSQL for persistence.
Architecture
The browser talks to Flask blueprints under routes/.
Game rules live in services/ (weapons, quests, RNGelo, Clash).
Persistence and matchmaking queries sit in db/database.py.
Client UI is orchestrated by static/js/main.js, with feature
modules under static/js/features/* and pure DOM helpers in
static/js/ui/*.
Browser (ES modules)
→ Flask routes (JSON APIs + Jinja pages)
→ services (loot, quests, Clash, RNGelo)
→ PostgreSQL (profiles, inventory, matches, quests)
← HTML / JSON / generated card images (Pillow)
Code exhibits
Short excerpts from the live codebase (trimmed for readability, not rewritten).
Python - procedural loot
services/weapons.py · generate_weapon
Rarity, stance, and affix slots are rolled server-side with entropy-seeded RNG before the item is persisted.
def generate_weapon(item_luck=1.0, stat_quality=1.0, forced_type=None):
random.seed(os.urandom(8) + str(time.time()).encode('utf-8'))
quality = roll_quality(item_luck)
q_data = qualities[quality]
stance_name = random.choice(list(WEAPON_STANCES.keys()))
stance = WEAPON_STANCES[stance_name]
base_image_type = random.choice(list(stance["subtypes"].keys()))
budget = random.randint(q_data["budget"][0], q_data["budget"][1])
min_roll = int(budget * stance["min_mult"])
max_roll = int(budget * stance["max_mult"])
available_affixes = get_affix_pool(quality)
chosen_affixes = random.sample(
available_affixes, min(len(available_affixes), q_data["slots"])
)
# …affix rolls, power score, flavor text…
Python - competitive rating (RNGelo)
services/rngelo.py · Elo + stance bonus
Clash uses classic Elo expectation with provisional K-factors and a small bonus for winning stance-advantage rounds.
def expected_score(rating: int, opp_rating: int) -> float:
return 1.0 / (1.0 + 10 ** ((opp_rating - rating) / 400.0))
def compute_delta(rating, opp_rating, games_played, score, stance_bonus=0) -> int:
e = expected_score(rating, opp_rating)
base = int(round(k_factor(games_played) * (score - e)))
return base + int(stance_bonus)
def cpu_synthetic_rating(streak_before=0, is_boss=False) -> int:
return DEFAULT_RATING + min(400, max(0, int(streak_before)) * 25) + (
100 if is_boss else 0
)
SQL - Async PvP matchmaking
db/database.py · get_random_pvp_opponents
Joins find full 3-slot loadouts, then Python bands by absolute RNGelo delta (±100 → ±250 → nearest).
SELECT
p.internal_user_id,
p.display_name,
p.avatar,
COALESCE(p.rngelo_rating, 1000) AS rngelo_rating,
SUM(w.power) AS total_power
FROM user_profiles p
JOIN user_inventory i ON p.internal_user_id = i.internal_user_id
JOIN weapons w ON i.weapon_uuid = w.uuid
WHERE i.slot_index < 3 AND p.internal_user_id != %s::uuid
GROUP BY p.internal_user_id, p.display_name, p.avatar, p.rngelo_rating
HAVING COUNT(i.slot_index) = 3
# Then fill up to 3 opponents preferring |Δrating| ≤ 100, then ≤ 250…
SQL - quest persistence
db/database.py · user_quests schema
Each player holds two active quest slots with a single requirement axis (tier / type / statted) stored as JSONB.
CREATE TABLE IF NOT EXISTS user_quests (
id SERIAL PRIMARY KEY,
internal_user_id UUID NOT NULL
REFERENCES user_profiles(internal_user_id) ON DELETE CASCADE,
slot_index INTEGER NOT NULL CHECK (slot_index IN (0, 1)),
axis TEXT NOT NULL CHECK (axis IN ('tier', 'type', 'statted')),
requirement JSONB NOT NULL,
quantity INTEGER NOT NULL,
reward_entropy INTEGER NOT NULL,
quality TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(internal_user_id, slot_index)
);
JavaScript - lazy inventory UI
static/js/features/inventory/inventory.js · renderInventory
Forge, Ledger, and Home share one inventory model. Only the active tab’s grids are rebuilt for mobile performance.
export function renderInventory() {
const mode = activeInventoryMode(); // forge | upgrades | home
// Only wipe/rebuild the visible tab's grids (lazy).
if (mode && gridsByMode[mode]) {
const g = gridsByMode[mode];
if (g.equip) g.equip.innerHTML = '';
if (g.stash) g.stash.innerHTML = '';
}
for (let slotIdx = 0; slotIdx < 23; slotIdx++) {
const item = state.playerInventory[slotIdx];
// …totals, then append createSlotHTML(…, mode) into active grids
}
}
JavaScript - Clash roll cinema
static/js/ui/animations.js · executeRollAnimation
Force rolls count up on a requestAnimationFrame tween; Twinned dual rolls get a compact layout on mobile hand cards.
export function executeRollAnimation(container, minBase, rolls) {
const compact = container.dataset.compactRolls === '1'
|| !!container.closest?.('[data-compact-rolls="1"]');
if (rolls.length === 1) {
container.innerHTML = `<div class="font-heading …">0</div>`;
animateValue(container.firstElementChild, minBase, rolls[0], 1500);
return;
}
// Twinned: two timelines, then strike-through the loser…
}