← Play LootRNG

LootRNG: Technical Overview

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.

Built by Charles Belton at Belton Creative · charles@beltoncreative.com

Python 3 / Flask PostgreSQL / SQL Vanilla JS (ES Modules) Jinja2 Tailwind Pillow Discord OAuth gunicorn

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…
}

Also in the stack

LootRNG is a living browser game stack: procedural loot, card strategy Clash, one Flask service, modular vanilla JS, and Postgres persistence. Focused on gameplay systems rather than a SPA framework.

Built by Charles Belton at Belton Creative · charles@beltoncreative.com

Play LootRNG →

Charles Belton · Belton Creative · Public technical stub