Alison Kohout Board Game Logger Project

Overview

Seeking to enhance the efficiency of logging my board games, I developed a comprehensive data system that tracks gameplay and reveals performance patterns. By leveraging AI prompting techniques, I identified Flask and PostgreSQL as suitable technologies for building a functional local web application. I then successfully implemented this solution despite having no previous web development background and only recently encountering SQL. The project has grown well beyond its origins. It is now a multi-user application with approved accounts and database-enforced data isolation, per-game trackers for six different games, a weighted game selector for picking what to play, an AI rules assistant grounded in uploaded rulebook PDFs and BoardGameGeek forum content, and a natural-language interface that writes and runs its own SQL against the log. The frontend has been rebuilt as a static site deployed on GitHub Pages, communicating with the Flask backend via API calls.

[GitHub →]    [Dashboard →]

Data Entry and Management

Users can seamlessly input game details including date played, game title, notes, results, and various scores. This data is securely stored and managed using a PostgreSQL database.

Code Snippet: Adding a played game to the database

                     
# The logger is now a JSON API behind a bearer token; the static frontend
# posts to this rather than submitting a server-rendered form.
#
# Every column a per-game tracker needs is written here, and the helpers
# (spirit_island_fields, ares_fields, ...) return NULLs for a game that
# does not use them — so one endpoint serves every game.

@app.route('/api/add_game', methods=['POST'])
def api_add_game():
    data = request.get_json() or {}
    spirit, adversary, level, scenario = spirit_island_fields(data)
    try:
        conn = get_db_connection()
        cur = conn.cursor()
        cur.execute(
            "INSERT INTO games (date_played, game_title, notes, result, level,"
            " my_score, bot_score, spirit, adversary, adversary_level, scenario,"
            " civilisation, my_civilisation, zoo_map, start_appeal,"
            " scenario_number, ares_temp_me, ares_oxygen_me, ares_oceans_me,"
            " ares_mc_me, ares_temp_bot, ares_oxygen_bot, ares_oceans_bot,"
            " ares_mc_bot)"
            " VALUES (" + ",".join(["%s"] * 24) + ") RETURNING id",
            (data.get('date_played'), data.get('game_title'), data.get('notes', ''),
             data.get('result', ''), data.get('level', ''),
             data.get('my_score', ''), data.get('bot_score', ''),
             spirit, adversary, level, scenario, imperium_civilisation(data),
             imperium_my_civilisation(data), *ark_nova_fields(data),
             scenario_number_value(data), *ares_fields(data))
        )
        # Returned so the form can attach a photo to the sitting it just made.
        new_id = cur.fetchone()[0]
        conn.commit()
        cur.close()
        conn.close()
        return jsonify({'success': True, 'id': new_id})
    except Exception as e:
        return jsonify({'success': False, 'message': str(e)}), 500

# The row itself carries a user_id and row-level security scopes it, so the
# endpoint does not have to be the boundary.

                     
                     
<!-- The form is static HTML on GitHub Pages. Game-specific field blocks are
     hidden until the title calls for them; only two are shown here. -->
<div class="form-container">
    <h2>Log a Game</h2>
    <label>Date Played: <span id="date-weekday"></span></label>
    <input type="date" id="date_played">
    <label>Game Title:</label>
    <div style="position:relative;">
        <input type="text" id="game_title" autocomplete="off">
        <div id="add_suggestions" class="autocomplete-suggestions"></div>
    </div>
    <label>Result:</label>
    <textarea id="result" rows="1" placeholder="Add result here..."></textarea>

    <!-- Spirit Island records three things that vary independently, so they get
         their own fields rather than being crammed into Level. Hidden until the
         title says Spirit Island. -->
    <div id="si-fields" style="display:none;">
        <label>Spirit:</label>
        <select id="spirit"><option value="">—</option></select>
        <label>Adversary:</label>
        <select id="adversary"><option value="">—</option></select>
        <select id="adversary_level"><option value="">Lvl</option></select>
        <label>Scenario:</label>
        <select id="scenario"><option value="">—</option></select>
    </div>

    <!-- Ark Nova: the map and the appeal you start the bot on are the setup,
         and the pair is what gets climbed. -->
    <div id="an-fields" style="display:none;">
        <label>Map:</label>
        <select id="zoo_map"><option value="">—</option></select>
        <label>Start appeal:</label>
        <input type="number" id="start_appeal" min="0" max="100" step="1">
    </div>

    <label>Notes:</label>
    <textarea id="notes" rows="4" placeholder="Add any notes here..."></textarea>
    <button id="log-btn">Log Game</button>
    <p id="log-status" style="display:none;"></p>
</div>

<script>
document.getElementById('log-btn').addEventListener('click', async () => {
    const title = document.getElementById('game_title').value.trim();
    const dp = document.getElementById('date_played').value;
    if (!title || !dp) { alert('Date and game title are required.'); return; }

    const payload = {
        date_played: dp, game_title: title,
        result: document.getElementById('result').value,
        notes: document.getElementById('notes').value
    };
    // Only send them for a Spirit Island play; the API ignores unknown names
    // anyway, but there is no reason to send four empty fields every time.
    if (isSpiritIsland(title)) {
        SI_FIELDS.forEach(id => payload[id] = document.getElementById(id).value);
    }
    if (isArkNova(title)) {
        AN_FIELDS.forEach(id => payload[id] = document.getElementById(id).value);
    }

    const data = await apiPost('/api/add_game', payload);

    // The photo needs the id of the row that was just written, so it goes up
    // after the play rather than with it.
    if (data?.success && pendingPhoto && data.id) {
        await uploadPhoto(data.id, pendingPhoto);
    }
    document.getElementById('log-status').textContent =
        data?.success ? 'Logged!' : (data?.message || 'Failed');
});
</script>

                     

Comprehensive Analysis

The application provides detailed statistics, highlighting top played games and game counts by week, month, and year. Users can access averages and identify the most played games over various time frames.

Code Snippet: Played board game statistics

                     
# One endpoint serves the whole statistics panel. Trimmed here to the shape:
# the real route also computes daily/weekly/monthly/yearly averages against a
# fixed reference date, and the longest-streak records.

@app.route('/api/dashboard')
def api_dashboard():
    conn = get_db_connection()
    cur = conn.cursor()

    cur.execute("SELECT DISTINCT game_title FROM games ORDER BY game_title")
    game_titles = [row[0] for row in cur.fetchall()]

    cur.execute("""
        SELECT game_title, COUNT(*) FROM games
        GROUP BY game_title ORDER BY COUNT(*) DESC LIMIT 5
    """)
    top_games = [{'game': r[0], 'count': r[1]} for r in cur.fetchall()]

    # "Today" is the user's today: each account stores its own timezone, so a
    # game logged at 11pm in Auckland does not land on yesterday in UTC.
    today = today_local()
    start_of_week = today - timedelta(days=today.weekday())
    start_of_month = today.replace(day=1)
    start_of_year = today.replace(month=1, day=1)
    end_of_last_week = start_of_week - timedelta(days=1)
    start_of_last_week = end_of_last_week - timedelta(days=6)

    def count(q, *args):
        cur.execute(q, args)
        return cur.fetchone()[0]

    def most_played(start, end=None):
        if end:
            cur.execute("""SELECT game_title, COUNT(*) FROM games
                           WHERE date_played BETWEEN %s AND %s
                           GROUP BY game_title ORDER BY COUNT(*) DESC LIMIT 1""",
                        (start, end))
        else:
            cur.execute("""SELECT game_title, COUNT(*) FROM games
                           WHERE date_played >= %s
                           GROUP BY game_title ORDER BY COUNT(*) DESC LIMIT 1""",
                        (start,))
        row = cur.fetchone()
        return {'game': row[0], 'count': row[1]} if row else {'game': None, 'count': 0}

    # Averages are each taken against a fixed reference date — omitted here,
    # along with the last-month and last-year periods, to keep the shape legible.
    records = {k: {'count': v['count'],
                   'start': v['start'].isoformat() if v['start'] else None}
               for k, v in period_records(cur).items()}

    result = {
        'game_titles': game_titles,
        'top_games': top_games,
        'today': today.isoformat(),
        'games_played': {
            'today':     count("SELECT COUNT(*) FROM games WHERE date_played = %s", today),
            'yesterday': count("SELECT COUNT(*) FROM games WHERE date_played = %s",
                               today - timedelta(days=1)),
            'this_week':  count("SELECT COUNT(*) FROM games WHERE date_played >= %s", start_of_week),
            'this_month': count("SELECT COUNT(*) FROM games WHERE date_played >= %s", start_of_month),
            'this_year':  count("SELECT COUNT(*) FROM games WHERE date_played >= %s", start_of_year),
            'last_week':  count("SELECT COUNT(*) FROM games WHERE date_played BETWEEN %s AND %s",
                                start_of_last_week, end_of_last_week),
            'daily_avg': daily_avg, 'weekly_avg': weekly_avg,
            'monthly_avg': monthly_avg, 'yearly_avg': yearly_avg,
        },
        'records': records,
        'most_played': {
            'this_week': most_played(start_of_week),
            'last_week': most_played(start_of_last_week, end_of_last_week),
            'this_year': most_played(start_of_year),
        },
    }
    cur.close()
    conn.close()
    return jsonify(result)

                     
                     
<!-- The panel is static markup; one call fills every figure in it. -->
<div class="stats-grid">
    <div><span class="stat-label">Today</span><span id="s-td">—</span></div>
    <div><span class="stat-label">Yesterday</span><span id="s-yd">—</span></div>
    <div><span class="stat-label">Daily average</span><span id="s-ad">—</span></div>
    <div><span class="stat-label">This week</span><span id="s-tw">—</span></div>
    <div><span class="stat-label">This month</span><span id="s-tm">—</span></div>
    <div><span class="stat-label">This year</span><span id="s-ty">—</span></div>
</div>
<h3>Most played</h3>
<table id="most-played">
    <tr><td>This week</td><td id="mp-tw-g">—</td><td id="mp-tw-c">0</td></tr>
    <tr><td>Last week</td><td id="mp-lw-g">—</td><td id="mp-lw-c">0</td></tr>
    <tr><td>This year</td><td id="mp-ty-g">—</td><td id="mp-ty-c">0</td></tr>
</table>
<ul id="top-games"></ul>

<script>
async function loadStats() {
    // apiGet attaches the bearer token and bounces to the login page on a 401.
    const d = await apiGet('/api/dashboard');
    if (!d) return;

    const gp = d.games_played;
    document.getElementById('s-td').textContent = gp.today;
    document.getElementById('s-yd').textContent = gp.yesterday;
    document.getElementById('s-ad').textContent = gp.daily_avg;
    document.getElementById('s-tw').textContent = gp.this_week;
    document.getElementById('s-tm').textContent = gp.this_month;
    document.getElementById('s-ty').textContent = gp.this_year;

    const mp = d.most_played;
    [['tw', mp.this_week], ['lw', mp.last_week], ['ty', mp.this_year]]
        .forEach(([k, v]) => {
            document.getElementById('mp-' + k + '-g').textContent = v.game || '—';
            document.getElementById('mp-' + k + '-c').textContent = v.count || 0;
        });

    document.getElementById('top-games').innerHTML =
        d.top_games.map(g => `<li>${escapeHtml(g.game)} — ${g.count}</li>`).join('');

    renderRecords(d.records, gp);
}
loadStats();
</script>

                     

Per-Game Trackers

Some games keep score in ways a generic log cannot capture. Each of these has its own page, built around the thing that game is actually about. A tracker only appears in the menu once that account has logged a play of the game, so nobody is shown pages for games they do not own.

  • Sleeping Gods — locations visited, totems collected, and how each voyage ended
  • Imperium — win/loss by civilisation, across every deck faced
  • Spirit Island — win grids by spirit, adversary, adversary level, and scenario
  • Ark Nova — results by zoo map against starting appeal
  • Cascadia — the solo scenario ladder, showing which rung is next
  • Ares Expedition — how close each finished game got to a terraformed board, charted per difficulty

A recurring detail in all of them: a sitting logged with no result is a game left set up, not a loss, and is excluded from win rates rather than dragging them down.

Sleeping Gods is the clearest case for this. It is an adventure game about exploring a world, and keeping notes as you play is an essential part of it — where you have been, what a location wanted, and what it gave you. The tracker below is that notebook, made searchable.

Code Snippet: Tracking games in Sleeping Gods
                     
# Sleeping Gods keeps a second table of its own: one row per location visited,
# with what it required, what it gave, and what it cost.
#
# Per-account since migration 014 — these rows carry a user_id and row-level
# security scopes them, so the endpoint is no longer the boundary.

@app.route('/search_sleeping_gods_location', methods=['GET'])
def search_sleeping_gods_location():
    # location is an integer column, so anything else is a 400 rather than a
    # 500 out of the driver. This only became reachable when the endpoint
    # stopped being owner-only — before that a stray value was refused earlier.
    try:
        location = int(request.args.get('term', '0'))
    except (TypeError, ValueError):
        return jsonify({'success': False, 'message': 'Location must be a number.'}), 400

    conn = get_db_connection()
    cur = conn.cursor()
    cur.execute("SELECT id,* FROM sleeping_gods WHERE location = %s", (location,))
    results = cur.fetchall()
    cur.close()
    conn.close()

    data = [
        {
            'id': row[0], 'location': row[1], 'part': row[2],
            'required_keyword': row[3], 'gained_keyword': row[4],
            'visited': row[5], 'notes': row[6],
            'combat': row[7], 'combat_level': row[8],
            'gained': row[9], 'lost': row[10],
            # ... the resource, condition and totem columns follow
        }
        for row in results
    ]
    return jsonify(data)


# Logging a location writes forty columns — resources required and gained,
# conditions taken and removed, totems, challenges. The helpers coerce each
# to the right type so a blank field becomes 0 rather than an error.

@app.route('/api/add_sleeping_gods', methods=['POST'])
def api_add_sleeping_gods():
    try:
        d = request.get_json() or {}
        def i(k): return int(d.get(k) or 0)
        def s(k): return str(d.get(k) or '')
        conn = get_db_connection()
        cur = conn.cursor()
        cur.execute(
            "INSERT INTO sleeping_gods (location, part, required_keyword,"
            " gained_keyword, visited, notes, combat, combat_level, gained,"
            " req_coins, req_meat, ... , gain_totem, challenge,"
            " challenge_level, gain_adventure)"
            " VALUES (" + ",".join(["%s"] * 40) + ")",
            (i('location'), s('part'), s('required_keyword'), s('gained_keyword'),
             '1' if d.get('visited') else '0', s('notes'),
             '1' if d.get('combat') else '0', i('combat_level'), s('gained'),
             i('req_coins'), i('req_meat'),
             # ... the remaining resource and condition fields
             s('gain_totem'), s('challenge'), i('challenge_level'),
             i('gain_adventure'))
        )
        conn.commit()
        cur.close()
        conn.close()
        return jsonify({'success': True})
    except Exception as e:
        return jsonify({'success': False, 'message': str(e)}), 500

                     
                     
<!-- Mid-voyage the two questions are "have I been here before?" and "where did
     I see that?", so location and note search are the two buttons. -->
<script>
    document.getElementById('search-loc-btn').addEventListener('click', async () => {
        const term = document.getElementById('search-loc').value;
        const data = await apiGet('/search_sleeping_gods_location?term='
                                  + encodeURIComponent(term));
        document.getElementById('search-results').innerHTML = renderResults(data || []);
    });

    document.getElementById('search-notes-btn').addEventListener('click', async () => {
        const term = document.getElementById('search-notes').value.trim();
        if (!term) return;
        const data = await apiGet('/search_sleeping_gods_notes?term='
                                  + encodeURIComponent(term));
        document.getElementById('search-results').innerHTML = renderResults(data || []);
    });

    async function deleteRow(id) {
        if (!confirm('Delete this entry?')) return;
        const data = await apiPost('/delete_sleeping_gods_row', { id });
        // Drop the row from the page rather than reloading the whole search.
        if (data?.success) document.getElementById(`row-${id}`)?.remove();
    }

    // A new voyage starts the map fresh, but the notes are worth keeping —
    // so this clears the visited flags without deleting anything.
    document.getElementById('reset-visited-btn').addEventListener('click', async () => {
        if (!confirm('Reset all visited flags?')) return;
        await apiPost('/reset_visited_sleeping_gods', {});
    });
</script>

                     
Code Snippet: Win / loss statistics for the board game Imperium
                     
# Win/loss per civilisation, grouped by expansion.
#
# The imperium view reads `games` with security_invoker, so it applies
# row-level security as whoever is asking — the owner gate that used to sit
# here was the only thing making this the owner's alone.

@app.route('/api/imperium_stats')
def api_imperium_stats():
    conn = get_db_connection()
    cur = conn.cursor()
    # One pass over the plays; the per-civilisation tally happens here rather
    # than in the 56 separate count queries the old page used.
    cur.execute("SELECT level, result, civilisation FROM imperium")
    plays = [{'level': r[0], 'result': r[1], 'civilisation': r[2]}
             for r in cur.fetchall()]
    cur.close()
    conn.close()

    # The recorded deck if there is one, otherwise read the old free text.
    # Resolved once per play so a play can never be counted under two decks.
    for p in plays:
        p['civ'] = imperium_civ_of(p)
        p['res'] = (p['result'] or '').lower()

    expansions = []
    for expansion, name, stars in IMPERIUM_CIVS:
        mine = [p for p in plays if p['civ'] == name]
        if not expansions or expansions[-1]['expansion'] != expansion:
            expansions.append({'expansion': expansion, 'civilisations': []})
        expansions[-1]['civilisations'].append({
            'name': name, 'stars': stars,
            'won': sum(1 for p in mine if 'won' in p['res']),
            'lost': sum(1 for p in mine if 'lost' in p['res']),
            # A game left set up is logged more than once, so a row is a
            # sitting. Finished games are the ones carrying a result.
            'games': sum(1 for p in mine if 'won' in p['res'] or 'lost' in p['res']),
            'plays': len(mine),
            # How many still rely on the free text. Zero once backfilled, and
            # worth seeing if it ever climbs again.
            'from_text': sum(1 for p in mine if not p['civilisation']),
        })

    # Text that names no known deck: a new expansion, a typo, or a play with no
    # opponent recorded at all.
    unmatched = sorted({(p['level'] or '').strip() for p in plays
                        if (p['level'] or '').strip() and not p['civ']})

    finished = sum(1 for p in plays if 'won' in p['res'] or 'lost' in p['res'])
    return jsonify({'expansions': expansions, 'unmatched_levels': unmatched,
                    'total_plays': len(plays),
                    'total_games': finished,
                    'recorded': sum(1 for p in plays if p['civilisation']),
                    'from_text': sum(1 for p in plays
                                     if p['civ'] and not p['civilisation'])})

                     
                     
<script src="./config.js"></script>
<script src="./api.js"></script>
<script src="./tabs.js"></script>
<script>
    requireAuth();
    renderTabs('games');

    const esc = s => String(s ?? '')
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

    async function load() {
        const data = await apiGet('/api/imperium_stats');
        const body = document.getElementById('body');
        if (!data) return;
        if (data.success === false) {
            body.innerHTML = `<p class="status-err">${esc(data.message || 'Not allowed.')}</p>`;
            return;
        }

        let totalWon = 0, totalLost = 0;
        const rows = data.expansions.map(group => {
            const civs = group.civilisations.map(c => {
                totalWon += c.won; totalLost += c.lost;
                // Greyed means never faced, not never finished. A deck you
                // sat down to and left mid-game still counts as tried.
                const played = (c.plays || c.won + c.lost) > 0;
                return `<tr class="civ${played ? '' : ' unplayed'}">
                    <td>${esc(c.name)}<span class="stars">${'★'.repeat(c.stars)}</span></td>
                    <td class="num lost">${c.lost || ''}</td>
                    <td class="num won">${c.won || ''}</td>
                </tr>`;
            }).join('');
            return `<tr class="exp-row"><td colspan="3">${esc(group.expansion)}</td></tr>${civs}`;
        }).join('');

        const unmatched = data.unmatched_levels.length
            ? `<p class="unmatched">Not matched to a civilisation:
                 ${data.unmatched_levels.map(esc).join(', ')}</p>`
            : '';

        body.innerHTML = `
            <table class="civs">
                <thead><tr><th></th><th>Lost</th><th>Won</th></tr></thead>
                <tbody>
                    ${rows}
                    <tr class="totals">
                        <td>Total — ${data.total_games} finished</td>
                        <td class="num lost">${totalLost}</td>
                        <td class="num won">${totalWon}</td>
                    </tr>
                </tbody>
            </table>${unmatched}`;
    }
    load();
</script>

                     

Accounts and Data Isolation

What began as a single-user local tool is now a multi-user application, which changes the security question entirely: it is no longer enough for the app to only ask for its own rows — the database has to refuse to return anyone else's.

  • Sign-up creates a pending account and emails the owner; nobody can log in until they are approved
  • The response to a sign-up is identical whether or not the email already exists, so the form cannot be used to discover accounts
  • Signed bearer tokens carry a version number, so changing a password invalidates every token already issued
  • Repeated failed logins for an address are throttled
  • PostgreSQL row-level security scopes every table to the account making the request
  • The app connects as a restricted role, not a superuser; a superuser connection would silently ignore those policies, so the riskiest endpoints check for one and refuse to run
  • Views run with the caller's privileges, so a view over a secured table cannot leak around the policy on it
  • An owner-only admin page approves, suspends, and removes accounts
Code Snippet: The shared fetch layer every page uses
            
// Shared auth + fetch helpers, loaded by every page. The token is issued at
// login and sent on every call; a 401 means it has expired or been revoked,
// so the page clears it and goes back to the login screen rather than
// rendering an empty dashboard.

function getToken() {
    return localStorage.getItem('bg_token');
}

function requireAuth() {
    if (!getToken()) {
        window.location.href = './index.html';
        throw new Error('Not authenticated');
    }
}

async function apiFetch(path, options = {}) {
    const token = getToken();
    const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
    if (token) headers['Authorization'] = 'Bearer ' + token;

    const resp = await fetch(API_BASE + path, { ...options, headers });

    if (resp.status === 401) {
        localStorage.removeItem('bg_token');
        window.location.href = './index.html';
        return null;
    }
    return resp;
}

// Fetch an image the same way as everything else — with a bearer token — and
// hand back a blob URL. An <img src> can't carry an Authorization header, and
// making photos publicly addressable would undo the isolation everything else
// has.
const _blobCache = new Map();

async function apiImageUrl(path) {
    if (_blobCache.has(path)) return _blobCache.get(path);
    const r = await apiFetch(path);
    if (!r || !r.ok) return null;
    const url = URL.createObjectURL(await r.blob());
    _blobCache.set(path, url);
    return url;
}

// Shrink a photo before it leaves the phone. A modern camera shot is 3-5 MB
// of detail nobody needs to see the state of a board, and uploading it over a
// mobile connection is the slow part.
const PHOTO_MAX_EDGE = 1600;

function shrinkImage(file, maxEdge = PHOTO_MAX_EDGE, quality = 0.82) {
    return new Promise(resolve => {
        const img = new Image();
        img.onload = () => {
            const scale = Math.min(1, maxEdge / Math.max(img.width, img.height));
            // Already small enough: don't re-encode and lose quality for nothing.
            if (scale === 1 && file.size <= 900 * 1024) { resolve(file); return; }
            const canvas = document.createElement('canvas');
            canvas.width = Math.round(img.width * scale);
            canvas.height = Math.round(img.height * scale);
            canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
            canvas.toBlob(
                blob => resolve(blob
                    ? new File([blob], 'photo.jpg', { type: 'image/jpeg' })
                    : file),
                'image/jpeg', quality);
        };
        img.onerror = () => resolve(file);   // not decodable here; let the server judge
        img.src = URL.createObjectURL(file);
    });
}

            

Table Photographs

Plays can carry photographs of the table, attached to the logged game and viewable from the games list.

Images are served through the same authenticated API as everything else rather than from a public URL, which would have undone the isolation the rest of the app enforces. Because an <img> tag cannot send an authorization header, the page fetches each photo in JavaScript and renders it from a blob URL.

Weighted Game Selector

Deciding what to play is half the battle. The game selector lets each player rank up to three preferences — first, second, and third choice — and then picks a game at random using weighted probabilities (first choice carries three times the weight of third choice).

  • Each player submits up to three ranked game preferences
  • A weighted random draw selects the game for the night
  • The pool can be cleared and rebuilt between sessions

AI Rules Assistant

The rules assistant uses the Claude API (Anthropic) to answer rules questions in natural language, grounded in the actual rulebook rather than relying solely on general training knowledge.

  • Upload a rulebook PDF once per game — stored in the database and reused for all future questions
  • Multiple rulebooks per game, so an expansion can be asked about alongside the base game
  • Text is extracted from each PDF on upload and sent instead of page images — roughly a third of the tokens, which is what keeps a game like Spirit Island inside the context limit
  • Books that are genuinely scanned images extract no text, so those are sent as pages automatically
  • Page images can also be requested deliberately, with the cost shown before the question is asked
  • Optionally paste BoardGameGeek forum threads as supplemental context, which are also cached per game
  • Ask any rules question and receive a direct, rulebook-grounded answer
  • Particularly useful mid-game when flipping through a rulebook is impractical

Ask the Database

A question in plain English — "which spirit have I won with most?" — is sent to Claude, which writes a PostgreSQL query against the live schema. The query is run, the rows are shown, and a second call turns them into a sentence or two of plain English. The generated SQL is displayed alongside the answer, so the working can always be checked.

Running model-written SQL against a real database is the interesting part of this feature, and it is defended in layers rather than by trusting the model:

  • Only a single SELECT or WITH statement is accepted, and a banned-keyword check rejects the rest
  • The query runs in a genuinely read-only Postgres transaction — enforced by the database, not by parsing the SQL
  • It executes as a restricted role that can read game rows and nothing else: no user table, no rulebooks
  • Row-level security still applies, so a generated query cannot reach another account's data
  • A statement timeout and a row cap bound what any single question can cost
  • If the app detects it is connected as a superuser — which would bypass those policies — the feature disables itself and says why
  • Questions the schema cannot answer come back as an explicit refusal rather than a guess
Code Snippet: Validating and running model-written SQL
            
# Running model-written SQL against a live database, defended in layers.

DB_QUERY_MAX_ROWS = 200               # rows handed back to the model and the page
DB_QUERY_MAX_CELL = 300               # characters per cell, so a stray blob can't flood

DB_QUERY_BANNED = re.compile(
    r'\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|copy|'
    r'vacuum|reindex|merge|call|do|lock|listen|notify|prepare|execute|'
    r'pg_read_file|pg_sleep|dblink|pg_terminate_backend)\b', re.I)


def db_query_check(sql):
    """Reject anything that isn't a single read-only statement. Returns an error string."""
    stripped = sql.strip().rstrip(';').strip()
    if not stripped:
        return 'No SQL was produced.'
    if ';' in stripped:
        return 'Only a single statement is allowed.'
    if not re.match(r'^(select|with)\b', stripped, re.I):
        return 'Only SELECT queries are allowed.'
    banned = DB_QUERY_BANNED.search(stripped)
    if banned:
        return f'Query rejected: it contains "{banned.group(0)}".'
    return None


def db_query_run(sql, is_owner=False):
    """Run the query in a read-only transaction. Returns (columns, rows, truncated).

    On a connection of its own, deliberately. set_session can't be called with a
    transaction already open, which the shared request connection usually has —
    and a pooled connection handed back still marked read-only would break the
    next writer to borrow it. The extra connection costs about a second; an AI
    question already costs several.
    """
    conn = private_db_connection()
    try:
        # Read-only is enforced by Postgres, not by our own parsing of the SQL.
        conn.set_session(readonly=True, autocommit=False)
        cur = conn.cursor()
        cur.execute("SET LOCAL statement_timeout = '15s'")
        # Hand the model's SQL to a role that can only read game rows: no users
        # table, no rulebooks, and row-level security still scoped to the caller.
        cur.execute("SET LOCAL ROLE " + ('bgl_ai_owner' if is_owner else 'bgl_ai'))
        cur.execute(sql)
        columns = [d[0] for d in cur.description] if cur.description else []
        raw = cur.fetchmany(DB_QUERY_MAX_ROWS + 1)
        truncated = len(raw) > DB_QUERY_MAX_ROWS
        rows = [
            ['' if v is None else str(v)[:DB_QUERY_MAX_CELL] for v in row]
            for row in raw[:DB_QUERY_MAX_ROWS]
        ]
        conn.rollback()
        cur.close()
        return columns, rows, truncated
    finally:
        conn.close()


@app.route('/api/ask_database', methods=['POST'])
def api_ask_database():
    question = (request.get_json() or {}).get('question', '').strip()

    # This endpoint runs SQL the model writes, so it is only safe while the
    # database is enforcing row-level security. A superuser connection ignores
    # policies, which would let a generated query read every user's rows.
    if db_bypasses_rls():
        return jsonify({'success': False, 'message':
                        'Disabled: the app is connected as a superuser, which bypasses '
                        'row-level security. Point DB_USER at bgl_app and restart.'}), 503

    is_owner = bool(current_user() and current_user().get('is_owner'))
    schema = db_query_schema(cur, is_owner=is_owner)

    # 1. Question -> SQL. A question the schema cannot answer comes back as an
    #    explicit refusal rather than a plausible-looking wrong query.
    sql_system = (
        'You write PostgreSQL for a personal board game log. Reply with one '
        'SELECT statement and nothing else — no explanation, no markdown fences, '
        'no trailing semicolon. If the question cannot be answered from this '
        'schema, reply with exactly "UNSUPPORTED: " followed by a short reason.\n\n'
        f'Schema:\n{schema}\n{notes}\n'
        f'Return at most {DB_QUERY_MAX_ROWS} rows — add a LIMIT unless the query '
        'is already an aggregate. Never select the columns marked HUGE.'
    )
    sql = ask_model('db_query', sql_system, question, 2000, sql_via_claude).text

    if sql.upper().startswith('UNSUPPORTED'):
        return jsonify({'success': False, 'message': sql.split(':', 1)[-1].strip()})

    problem = db_query_check(sql)
    if problem:
        return jsonify({'success': False, 'message': problem, 'sql': sql}), 400

    # 2. Run it read-only, then 3. turn the rows back into plain English.
    columns, rows, truncated = db_query_run(sql.rstrip(';'), is_owner=is_owner)
    answer = ask_model('db_query', answer_system, answer_user, 1000, answer_via_claude).text

    # The generated SQL goes back with the answer, so the working can be checked.
    return jsonify({'success': True, 'answer': answer, 'sql': sql,
                    'columns': columns, 'rows': rows, 'truncated': truncated,
                    'cost_nzd': round(nzd, 4)})

            

AI Cost Control

The AI features cost real money per question, so the app accounts for it rather than hoping for the best.

  • Every AI call records its token usage and cost, in both USD and NZD
  • Each account holds a credit balance, with recent usage and top-up history shown on its own page
  • Top-ups run through Stripe Checkout; the amount is chosen from a server-side list, and only the signed webhook marks a purchase paid — a success redirect can be forged, a signature cannot
  • The rules assistant estimates what sending a rulebook as page images will cost before the question is asked
  • Where a locally hosted model is configured, it answers instead — at no per-question cost — falling back to Claude if it is unreachable