My friends and I play Arma 3.
Not particularly casually.
We have squads, assigned roles, radio procedures, tactical equipment, mission briefings, and an increasingly questionable commitment to pretending that we're part of an actual military organization.
Naturally, we also spend an unreasonable amount of time on Discord.
You could call it LARPing.
LARP originally stands for Live Action Role-Playing - physically acting out fictional characters and scenarios. In online gaming communities, particularly military simulation communities, the word is often used a little differently. It's what happens when people start taking their virtual military careers just a bit too seriously.
Think organized squads, proper radio communications, chains of command, and somebody genuinely arguing about the correct way to conduct a fictional military operation.
At some point, I had an idea.
What if I could actually measure how much we LARP?
Who spends the most time playing Arma? Who is permanently sitting in voice chat? Who gets knocked unconscious the most? Who contributes to the group, and who just spends three hours arguing over equipment?
And, perhaps most importantly, who deserves the title of LARP God?
The obvious solution would have been a simple Discord leveling bot.
Instead, I ended up building a TypeScript monorepo with PostgreSQL, a background worker, a web dashboard, an achievement system, optional AI commentary, and a native C++ telemetry bridge for Arma 3.
All to measure something that was originally an inside joke.
Why a Normal Leveling Bot Wasn't Enough
Most Discord leveling bots operate on a fairly simple principle.
Send a message. Receive XP. Send more messages. Receive more XP.
Eventually, you get a role with a slightly more impressive name.
That's fine if your goal is to encourage people to chat. But it doesn't really measure what I was interested in.
Someone sending 200 meaningless messages shouldn't necessarily outrank someone who spent six hours actively playing Arma with the group.
Similarly, sitting alone in a voice channel for an entire afternoon shouldn't count the same as participating in a coordinated mission with your friends.
I wanted something that measured actual community activity rather than just message frequency.
That meant collecting several different kinds of information:
Messages: How much someone communicates, rather than simply rewarding spam.
Voice: How long people spend together, including who's actually present.
Game presence: What people are playing and for how long, according to Discord.
Arma telemetry: What actually happens during missions.
Progression: Achievements, medals, records, and reputation.
The important distinction was that XP would be only one output.
The more interesting part would be the data underneath it.
The Architecture Got Out of Hand
The project is called LARP.
It's not just a Discord bot anymore. It's a collection of services sharing a PostgreSQL database and a common TypeScript codebase.
The main stack looks like this:
TypeScript / Node.js
Discord.js
Fastify
Next.js
PostgreSQL 17
Drizzle ORM
pg-boss
Zod
Docker Compose
Arma 3 integration:
SQF
C++
HMAC-SHA256Each component has a reasonably clear responsibility.
The Discord bot listens to gateway events, records activity, and provides commands.
The Fastify API exposes data to the dashboard and receives telemetry from Arma 3.
The PostgreSQL database stores activity, sessions, progression, achievements, and administrative records.
The background worker handles recurring tasks, session recovery, achievement evaluations, and queued operations.
The Next.js dashboard provides an actual interface for exploring the collected data.
And then there's the Arma 3 integration, which consists of an SQF addon and a native Linux extension responsible for delivering game events to the backend.
Everything is connected, but the responsibilities remain separate.

LARP's architecture: two independent telemetry sources, a shared PostgreSQL backend, and a dashboard bringing everything together.
The architecture is excessive for tracking a few friends playing video games.
I'm aware of that.
But having separate components also meant I could expand the system without turning the Discord bot into one enormous application responsible for everything.
Recording Discord Activity Without Storing Everything
The first major design decision was separating collecting activity from awarding XP.
A message being sent doesn't automatically mean someone deserves points.
It's an event that contains information.
When someone sends a message, the bot can extract characteristics such as:
Character and word counts.
Links and attachments.
Images.
Mentions and replies.
Whether the message is eligible for progression.
The ordinary telemetry path doesn't need to permanently store the actual message text.
Instead, it processes the message and stores derived statistics.
There are also minimum-length requirements, cooldowns, and daily caps.
Otherwise, someone could write a script that sends the same character repeatedly and become the highest-ranked member overnight.
Technically impressive, perhaps.
Not particularly useful as a measure of community participation.
Voice Activity Is More Interesting
Voice tracking sounds simple.
Record when someone joins a channel.
Record when they leave.
Subtract the timestamps.
Done.
Except that this ignores almost everything interesting about voice activity.
Imagine someone sits alone in a channel for three hours.
Now imagine four people spend three hours together preparing and running an Arma mission.
Those are very different situations.
So instead of treating voice activity as a single timer, LARP stores it in segments.
Each segment captures the channel, participants, and relevant voice state.
For example, the database schema includes:
participants: integer('participants').notNull(),
participantIds: jsonb('participant_ids')
.$type<string[]>()
.notNull()
.default([]),
selfMuted: boolean('self_muted').notNull(),
selfDeafened: boolean('self_deafened').notNull(),
serverMuted: boolean('server_muted').notNull(),
serverDeafened: boolean('server_deafened').notNull(),When the relevant state changes, the current segment is closed and another begins.
This allows the system to distinguish between solo and social voice time, preserve state changes, and aggregate time spent together by pairs of members.
It's considerably more useful than a simple number representing minutes connected.
And it creates the possibility of finding out exactly which two people spend the most time together on Discord.
Whether that's a useful statistic or evidence that we should all go outside is another question.
What Happens When the Bot Crashes?
Time-based tracking introduces another problem.
Suppose someone joins voice chat at 20:00.
The bot records the beginning of their session.
At 20:30, the bot crashes.
It comes back online two hours later.
If the system simply uses the current time to calculate the session duration, it could award hours of activity that it never actually observed.
To avoid this, active sessions maintain a last known heartbeat.
The bot and worker can recover stale sessions and close them at the last recorded heartbeat rather than at the moment recovery happens.
Conceptually:
20:00 Voice session opened
20:01 Heartbeat received
20:02 Heartbeat received
20:03 Heartbeat received
20:04 Bot crashes
...
22:00 Bot restarts
...
Session recovered
End time: 20:03This might lose a small amount of legitimate activity.
But that's considerably better than inventing two hours of participation.
It's one of those problems that seems completely unnecessary until you're building a system that actually needs accurate time accounting.
Turning Activity Into Progression
Collecting data is only half the problem.
At some point, I needed to decide what all of that activity was worth.
I wanted progression to be predictable, configurable, and independent of any AI model.
The default scoring rules include:
Social voice: 1 XP per 10 minutes.
Solo voice: 0.25 XP per 10 minutes.
Messages: 1 XP per five eligible messages.
Arma 3: 4 XP per 10 minutes of detected playtime.
Roblox: 2 XP per 10 minutes of detected playtime.
Other games: 0.5 XP per 10 minutes.
Daily limits and eligibility rules still apply.
Activity between midnight and 05:00 local time can also receive a 1.25× night multiplier.
Because apparently playing Arma at 3 AM deserves additional recognition.
Internally, XP is stored as milli-XP.
One XP equals 1,000 milli-XP.
This makes fractional rewards easier to represent consistently without relying on floating-point arithmetic for stored XP values.
Level Progression
Levels use a surprisingly simple formula:
export function levelForXp(xpMilli: number): number {
return Math.floor(
Math.sqrt(Math.max(0, xpMilli) / 100_000)
);
}This produces quadratic cumulative XP thresholds.
Each successive level takes progressively more activity, without requiring a giant manually maintained table of XP requirements.
The titles are considerably less serious than the implementation.
They include names such as:
Chairborne Ranger
PMC Intern
LARP God
Exactly the kind of professional military terminology this project deserves.

The LARP dashboard brings together player progression, live Arma 3 server telemetry, and Discord voice activity in a single interface.
188 Achievements, Because Apparently 20 Weren't Enough
XP alone gets boring eventually.
I wanted people to be recognized for specific behaviors rather than just their total activity.
The seeded achievement catalog documents 188 enabled achievements, each with its own conditions.
These aren't simply hardcoded commands.
The system evaluates data-driven definitions involving lifetime statistics, time windows, streaks, event counts, dependencies, and combinations of requirements.
Some examples:
First Blood
Register a verified hostile infantry kill.
Mission Veteran
Participate in 25 server-recorded Arma mission runs.
Field Casualty
Experience 10 distinct ACE unconscious episodes.
That last one might be my favorite.
Most games reward you for surviving.
This system recognizes your commitment to repeatedly becoming a medical emergency.
Achievements are intended to be one-time awards. Database constraints prevent the same definition from being awarded to the same member repeatedly.
Then There Are Medals
Achievements weren't enough, apparently.
The seeded catalog also documents 52 enabled medals.
Unlike achievements, medals can be awarded repeatedly.
They support tiers including Basic, Bronze, Silver, Gold, Platinum, and Classified.
Repeat awards have diminishing XP rewards:
First award: 100% XP.
Second award: 50% XP.
Third award: 25% XP.
Subsequent awards: 0% XP.
Examples include Friendly Fire Incident and Hero of the Day.
Achievements mark milestones.
Medals recognize events and recurring behavior.
The result is a progression system that can reward both long-term participation and specific moments worth remembering.
Command Reputation: Judging Each Other Professionally
Not everything important can be measured automatically.
Someone might coordinate a mission exceptionally well, fix a technical problem, help another player, or make an impressively questionable tactical decision.
So I built Command Reputation.
It's a separate system in which authorized members can issue positive or negative reputation events.
Some of the categories include:
POSITIVE
CLUTCH
TECH
HELPFUL
NEGATIVE
FF
AFK
YAPPINGThere are role-based limits.
Officers can issue up to ±1, Senior Officers up to ±3, and Administrators up to ±5.
Ordinary members cannot issue reputation.
Every event records who issued it, who received it, the category, and the reason. Revocations are recorded as well.
Command Reputation is also deliberately separate from XP.
I wanted a distinction between automatically observed activity and subjective recognition.
Both are interesting.
They just shouldn't be treated as the same thing.
And if someone receives negative reputation for YAPPING, there should at least be an auditable record of who made that decision.
Then I Connected It to Arma 3
At this point, LARP could already track Discord messages, voice activity, and game presence.
But there was a problem.
Discord Presence can tell me that someone appears to be playing Arma 3.
It can't tell me what happened during their mission.
It doesn't know whether somebody completed an operation, destroyed a vehicle, killed an enemy, or spent half the evening unconscious.
I wanted that information too.
So I decided to get data directly from the game server.

A first-person view from one of our FPV drone operations in Arma 3. Yes, we take the simulation this seriously.
Getting Events Out of the Game
Arma 3 supports scripting through SQF.
The server-side addon uses this to observe mission and gameplay events.
The telemetry system supports event types including:
mission_start
mission_end
player_join
player_leave
heartbeat
kill
player_death
vehicle_destroy
ace_unconscious
ace_recovered
server_startup
server_shutdown
server_heartbeat
bridge_statusEach mission receives its own identifier.
This lets the application associate events with specific mission runs rather than trying to infer everything from Discord Presence.
Combat events can include additional information about the killer, victim, weapon, sides, friendly-fire status, and unit classification when that information is available.
Now, instead of merely knowing that someone played Arma for three hours, the application can maintain records of what the server observed during that session.
That makes the achievement system considerably more interesting.
It also means that claiming you didn't accidentally shoot a teammate becomes a little more difficult.
The Native C++ Bridge
Getting events out of Arma was where the architecture became particularly interesting.
The addon runs in Arma's SQF environment.
The backend is written in TypeScript.
Between them sits a native Linux extension called larp_telemetry, written in C++.
Its job is to receive events from the game and deliver them to the Fastify API.
That sounds simple.
It isn't quite.
What happens when the API is temporarily unavailable?
What happens when the network connection fails?
What happens when an event reaches the API but the acknowledgment never reaches the sender?
A telemetry system that silently loses events whenever something goes wrong isn't especially useful.
So the extension maintains an event queue and a persistent JSONL outbox.
Failed deliveries are retried using exponential backoff.
The extension also exposes diagnostic information such as queue depth, retry count, spool size, and the last delivery error.
The backend validates incoming events and deduplicates them using their server and event identifiers.
The resulting flow looks like this:
Arma produces an event.
The SQF addon passes it to the native extension.
The extension queues and persists it.
A signed HTTP request is sent to the API.
The API authenticates and validates the request.
PostgreSQL stores the event without duplicating already-processed event IDs.
The data becomes available for statistics and progression.
Retries are an expected part of the system, not automatically an error.
If the same event is delivered twice, it shouldn't become two kills or two achievements.
Authenticating the Game Server
I also didn't want arbitrary clients to submit fake mission telemetry.
The API uses HMAC-SHA256 to authenticate requests from the game integration.
The signature covers the HTTP method, path, timestamp, server identifier, and raw request body.
A simplified excerpt from the implementation:
return createHmac('sha256', secret)
.update(
`POST\n/integrations/arma/v1/events\n${timestamp}\n${serverId}\n${rawBody}`
)
.digest('hex');The receiving API independently verifies the signature.
Combined with schema validation and event deduplication, this provides a much stronger ingestion boundary than simply exposing an endpoint that accepts any JSON payload.
It doesn't guarantee that every event reported by the game is correct.
But it prevents unauthenticated clients from impersonating the configured telemetry source.
Discord IDs and Steam IDs Are Not the Same Thing
There was another problem.
Discord identifies members through Discord user IDs.
Arma identifies players through Steam identities.
The database needs to know which Steam account belongs to which Discord member.
Rather than guessing, LARP uses explicit account linking.
When a game event arrives, the API can resolve the reported Steam identity to a linked and tracked Discord member.
Actors and subjects are resolved independently.
That matters for events involving multiple people.
For example, a kill event can have a player attacker and a different player victim.
If an identity isn't linked, the system preserves that resolution state rather than assigning the event to somebody arbitrarily.
Correct identity attribution isn't the most exciting feature.
But without it, the statistics would be unreliable.
Tracking ACE Medical Events
We use ACE3, which adds more detailed medical mechanics to Arma.
One of the things I wanted to track was how often someone becomes unconscious.
The obvious implementation would be periodic polling.
Every minute, check whether the player is unconscious.
If the state changed, record an event.
Except somebody can become unconscious and recover between two checks.
The system would never know it happened.
The addon therefore uses ACE's ace_unconscious event when the required event framework is available, with polling as a fallback.
Both mechanisms share transition state so they don't independently report the same event.
The core state-transition logic resembles:
if (_state && {!_before}) then {
['ace_unconscious', _uid] call LARP_fnc_emit
};
if (!_state && {_before} && {alive _unit}) then {
['ace_recovered', _uid] call LARP_fnc_emit
};The event handler provides timely updates.
The polling fallback provides a second way to detect changes.
Shared state helps prevent duplicates.
All of this engineering exists partly so I can accurately count how often my friends get knocked unconscious in a video game.
I consider that a perfectly reasonable use of technology.

At the time of this screenshot, the ingestion dashboard reported 8,491 accepted telemetry requests over the previous 30 days.
Giving All This Data a Proper Dashboard
Discord commands are useful for quick interactions.
They're considerably less useful when you want to explore several weeks of activity, compare statistics, or inspect individual events.
So I built a web dashboard with Next.js.
Users authenticate using Discord OAuth.
The API checks that they're members of the configured Discord server, then determines their permissions from Discord roles and server-side authorization rules.
The dashboard provides access to features such as:
Personal profiles and activity statistics.
XP, levels, achievements, and medals.
Guild rankings and community records.
Arma analytics and telemetry diagnostics.
Campaigns, squads, and operation planning.
Calendars, RSVPs, polls, and notifications.
Administrative tools and audit information.
The important part is that the dashboard and Discord bot use the same underlying application data.
I didn't want the bot to calculate one score while the dashboard independently calculated another.
The application is structured around shared progression logic and a common database.
Administrative Actions Need Real Permissions
Once a dashboard can modify XP, adjust scoring rules, or issue reputation events, access control becomes important.
Discord authentication alone isn't enough.
Being a member of the server shouldn't automatically grant administrative permissions.
The API implements separate access checks for member, administrator, and designated Discord moderation operations.
Administrative changes have audit paths, including manual XP adjustments and reputation events.
Even though the competition itself is absurd, I still want the system to behave predictably.
Especially when somebody inevitably complains that they deserve more XP.
Adding AI Without Letting It Make Things Up
Naturally, I also wanted the bot to insult people.
So I added optional AI commentary.
The bot includes commands such as:
/roast
/judge
/intel
/propaganda
/debrief
/excuseThe implementation supports OpenAI-compatible providers, including OpenAI and DeepSeek, as well as a mock provider used for testing.
But there was one rule I wanted to maintain:
AI should comment on the statistics, not invent or calculate them.
The actual XP system, achievements, and activity tracking are deterministic.
The language model doesn't get to decide that someone deserves another 500 XP because their mission sounded impressive.
Instead, the application constructs a limited context containing selected facts.
For example:
const memberFactKeys = [
'id',
'name',
'xp_milli',
'rating'
] as const;
const statsFactKeys = [
'voice_seconds',
'messages',
'arma_seconds',
'roblox_seconds',
'other_game_seconds',
'night_seconds',
'active_days'
] as const;The model can use these facts to generate commentary.
It shouldn't claim someone played 40 hours of Arma if the supplied statistics say otherwise.
The integration also includes timeouts, short output limits, request accounting, cost estimates, and a monthly budget safeguard.
AI is disabled by default and requires explicit provider configuration.
The whole platform should continue working even when no language model is connected.
The AI is there to make the data entertaining.
Not to become the source of truth.
A Few Things I Took Seriously
The project started as a joke.
But collecting information about real people creates responsibilities, even in a private Discord community.
Data Collection and Privacy
I didn't want ordinary activity tracking to require saving the contents of every message.
The regular message telemetry path stores derived statistics rather than the original text.
There's a separate, configurable AI archive that can retain messages from specifically enabled channels, subject to member memory preferences.
Those are different data-processing paths, and it's important not to confuse them.
The Arma integration also relies on explicit identity linking instead of automatically associating every Steam account with a community member.
Unmatched Steam identifiers have a documented pruning period.
Some categories of ordinary activity and aggregate data, however, don't currently have automatic expiration policies.
More complete retention and deletion controls are among the areas that still need improvement.
Idempotency and Consistency
Another recurring concern was preventing events from being counted twice.
This appears in several places.
XP events have unique idempotency keys.
Achievement evaluations use database transaction locking to reduce conflicting awards.
Arma telemetry uses source-scoped event identifiers.
Session recovery avoids creating activity during unobserved downtime.
These mechanisms solve different versions of a similar problem.
The system should not reward activity that didn't happen, or count the same activity twice.
That's particularly important in a progression system where every number can affect someone's ranking.
Deployment and Observability
The repository includes a Docker Compose setup with PostgreSQL, a migration service, the API, web dashboard, worker, and an optional Discord bot profile.
Database changes are managed through Drizzle migrations.
The API exposes health and readiness endpoints, and the codebase includes metrics, logging, telemetry diagnostics, and automated tests.
Testing infrastructure includes Vitest and Playwright.
But Docker Compose, tests, and monitoring endpoints aren't magic guarantees of production readiness.
Some administrative corrections still need better downstream recalculation.
Certain authenticated browser flows need stronger test coverage.
Discord Presence remains an imperfect measure of actual game playtime.
And game telemetry depends on the correct Arma addon, native extension, and server configuration.
These aren't reasons to abandon the project.
They're simply part of building a system that does more than respond to a few Discord slash commands.
What I'd Like to Build Next
The foundation already supports several kinds of activity and game telemetry.
What interests me now is making the resulting statistics more meaningful and easier to explore.
In particular, I'd like to improve the Arma side.
Mission history could become more useful with richer player statistics, comparisons, and detailed reports.
The existing game event types provide a starting point, but turning raw events into genuinely useful analytics takes additional work.
I'd also like to make progression more transparent.
If somebody gains XP, they should be able to understand exactly why.
If somebody receives an achievement, the underlying event or condition should be inspectable.
And if the game reports an event that can't be associated with a Discord member, the system should make that visible instead of silently guessing.
Beyond that, better retention controls, administrative correction workflows, and automated coverage would make the platform more robust.
They're not necessarily the most exciting features to show off.
But they're the kind of features that make the difference between a fun prototype and a system you can actually trust.
What I Learned From Overengineering an Inside Joke
The most interesting thing about LARP is how quickly such a simple idea turned into problems from several different areas of software engineering.
Discord activity tracking involves event processing.
Voice tracking involves time, state, and recovery.
Progression involves deterministic scoring and consistency.
Arma integration involves communication across different runtimes, authentication, and unreliable delivery.
The web dashboard involves identity, authentication, and authorization.
AI introduces questions about factual grounding, safety, and cost.
And the whole thing needs deployment, observability, and maintenance.
None of these problems is unique to a Discord bot.
They're the same kinds of problems that appear in real analytics platforms, distributed systems, multiplayer backends, and business applications.
I just encountered them while trying to determine which of my friends is the biggest LARPer.
If there's one principle I'd take away from the project, it's this:
Collect facts first. Interpret them separately.
Discord messages, voice sessions, and Arma events are observations.
XP, achievements, ratings, and leaderboards are interpretations of those observations.
AI commentary is another layer on top.
Keeping those responsibilities separate makes the system easier to understand, test, and extend.
Was It Worth Building?
I could have installed an existing Discord leveling bot.
It probably would have taken five minutes.
Instead, I ended up with a self-hosted platform that combines Discord activity, persistent session tracking, game telemetry, deterministic progression, achievements, medals, reputation, and optional AI commentary.
There's a PostgreSQL database.
There are background workers.
There's a web dashboard.
There's a native C++ extension communicating with an Arma 3 server.
And there's an achievement for repeatedly becoming unconscious.
The architecture is excessive for its original purpose.
I know.
But it also turned a ridiculous idea into a project involving genuinely interesting engineering problems.
And if several thousand lines of code can help determine who deserves the title of LARP God, I suppose that's infrastructure worth building.