Skip to content
AKRAmit Kumar Raikwar
All posts
Engineering8 min read

An esports platform is mostly a clock

Finalist runs a scrim night with nobody watching it: registration opens, slots fill, lineups get filtered, the room reveals. Almost every hard problem in the build turned out to be a scheduling problem.

EsportsSchedulingPostgreSQLRedisDiscord

A scrim org runs on four apps. A Discord server for the community, one spreadsheet for the slot list, a second spreadsheet for the points table, and the captain DMs where the room ID and password end up. It works. It works right up until the night somebody screenshots the password into a public channel, or twelve teams turn up for eleven slots because nobody noticed the waitlist needed backfilling, and the evening goes on arguing instead of playing.

Finalist is what we built for that. It is live at https://finalist.live/ and it covers registration, slot lists, room details, results and leaderboards for both scrims and tournaments, with a Discord bot mirroring the whole thing into the server people are already sitting in.

The part worth writing down is where the work went. Read the feature list and this is a CRUD app with brackets bolted on. Build it and you find that the central object has a start time, and everything difficult follows from that one fact.

The host is in the lobby, not on the dashboard

Here is one scrim from the organizer's side. They set it up in the afternoon and at nine in the evening they are in a game, playing, like everyone else.

# One scrim with a 9:00pm start. Every time
# below is derived from that single figure.
#
#   1:00pm   host creates it, sets 9:00pm, leaves
#   3:00pm   registration opens    start - 360m
#   8:30pm   registration closes   start - 30m
#   8:45pm   pre-match filter      start - 15m
#              players with no IGN dropped
#              teams under minimum unregistered
#              freed slots backfilled from waitlist
#   8:55pm   room details reveal to slotted captains
#   9:00pm   status moves to ongoing
#
# The host is in the lobby for the last four.

Four of those five transitions carry a consequence somebody notices within seconds. Registration closing on the wrong minute costs a team its slot. The filter running twice takes slots back off teams it has just promoted. The room revealing early puts a password in front of people who are not playing. The scheduler is not a background convenience in this product. It is the primary surface, and it has no interface at all.

Offsets, not timestamps

The first version I sketched put two datetimes on each scrim: one for when registration opens, one for when it closes. That is the obvious shape and it breaks on the second feature anybody asks for.

Organizers do not run one scrim. They run the same scrim every night at nine for a whole season, so there are recurring presets, and a preset is a template that stamps out one scrim per day. Store absolute times on a template and two things go wrong. Move the start from nine to eight and the registration window stays where it was. Cross a daylight saving boundary and every scrim after it opens an hour off, in a way nobody catches until captains start complaining.

So the windows are stored as minutes before start. 360 to open, 30 to close, another number for when the filter runs. The start time is the only clock value in the record and the rest is arithmetic on it. Move the start and the evening moves with it, because there is nothing else to move.

# The shape of a recurring preset.
# One clock value, the rest are offsets.
#
#   start_time              21:00
#   timezone                Asia/Kolkata
#   cadence                 daily
#   duration_minutes        90
#   open_offset_minutes     360
#   close_offset_minutes    30
#   filter_offset_minutes   15
#
# Node runs under TZ=UTC. The preset's own
# timezone is applied when a scrim is stamped
# out of it, so 21:00 stays 21:00 for that org
# whatever the server thinks the date is.

The timezone sits on the preset rather than on the server or on the person who clicked save. An org in Delhi running nightly BGMI scrims wants nine o'clock to mean nine o'clock in October and in January. Every process runs under TZ=UTC so nothing local leaks in, and the conversion happens once, at the moment a scrim is created from the template.

One tick a minute, and a lock

A lifecycle worker wakes up every minute, asks the database which time-driven edges are now due, and advances them. The interval is a choice rather than a limit. Second-level precision on registration opening is worth nothing to anybody, and a minute of granularity keeps the query cheap enough to run forever.

The problem with a worker is that you eventually have two of them. A deploy overlaps. A container restarts before the old one has drained. Both replicas wake up, both see the same scrim with a filter pass due, and both run it.

Most double-fires are harmless, because the transition is a status write landing on the same value twice. The filter is not one of those. It removes players with no in-game name, unregisters the teams that fall under the minimum lineup size, then promotes waiting teams into the slots it has just freed. Run it again thirty seconds later and it evaluates the teams it promoted, several of which have not finished filling their lineups, and takes their slots back.

Every edge therefore takes a Redis lock keyed on the event and the transition, using Redlock over ioredis, held for longer than the transition can take. The filter also records that it has run. The lock covers the race between two workers and the flag covers everything the lock cannot see, such as a worker that died holding it.

A scheduled job gives you at least once. Exactly once is the two lines you write around it.

Consequences go on a queue

A transition is a database write and it should finish like one. What follows a transition is slower: notify twelve captains in-app, send twelve pushes, post an announcement embed into a Discord channel, DM the captains whose teams the filter dropped. Discord has rate limits and push providers have bad afternoons.

All of that goes on BullMQ. The worker moves the state and enqueues the consequences, which keeps the minute tick a fixed cost whatever the Discord API is doing that evening. It also makes the failures inspectable. A push that never arrived is a job with a retry count on it rather than a log line somebody has to go looking for.

The room password is an authorization check

The failure in the first paragraph, the password in a public channel, is the one people ask about. Here is the shape it settled into.

Room details are key value pairs: room ID, password, region, map, and whatever else an org wants to add. Any pair can be marked secret, the password is marked secret by default, and a secret pair stays masked until a captain chooses to reveal it. Published details are visible to the captain of a team holding a slot and to nobody else.

The Discord side is where the decision is. The bot posts one announcement into the channel with a reveal button on it. The button is public. Anyone in the server can press it. Authorization runs server-side on every press, and what comes back is either the credentials, privately, or a line explaining that only slotted captains can see them.

The alternative was DMing each captain. That is twenty-five separate messages, twenty-five chances to address the wrong account, and a rate limit to work around, for the same result. One message with a check behind it is less code and a smaller surface for a mistake.

This one went to Postgres

I have written here before that I reach for MongoDB first on most projects, because in the first six weeks the shape of the data is still moving. Finalist went the other way, and it was not a close call.

Look at what has to hold. A slot belongs to one team. A lineup belongs to one slot and one scrim, and editing it does not touch the team's roster. Points belong to one confirmed match. A standings row belongs to one stage and has to equal the sum of the matches under it. When a referee reopens a completed match and corrects a placement, every standings row downstream recomputes and the qualification cut moves with them.

That is correctness across rows, which is the case where a document store makes me hand-build a guarantee Postgres already ships. Prisma sits on top of it, and points are frozen at the moment a result is recorded, so a later change to the scoring table cannot rewrite standings people have already screenshotted.

The feature we refused to model

Every scrim platform brief in India opens with a wallet. Teams pay an entry fee, the pot funds the prize, the platform holds the money in between and takes a cut. It is the obvious business model and it is why a lot of these products exist.

Finalist does not move money anywhere. No wallet, no entry-fee field, no gateway, no escrow. The reason is India's Promotion and Regulation of Online Gaming Act, 2025, which draws a hard line between esports and online money games. Orgs that charge entry collect it themselves, off the platform. Prizes are recorded as bands and payout references, so a tournament page can state what was won without the platform ever having held it.

The engineering point is about where a decision like that lives. Subscription plans have their limit plumbing left in the code, unused, because switching them on later is a data change. Money has no plumbing at all. A disabled entry-fee column is a column somebody enables in eighteen months without reading the statute, and the cheapest way to keep it switched off is for it never to have existed.

What carries over

If the central object in what you are building has a start time, you are writing a scheduler with an interface on it, and it is worth knowing that in week one instead of week six. Three things from this build I expect to reuse.

Store the one time you were given and derive the rest. Offsets survive a moved start time and a timezone change. Stored timestamps survive neither.

Assume two copies of your worker are awake right now. Take a lock per transition, and separately record that the destructive ones have already run.

Split a transition from its consequences. State moves in a transaction, notifications go on a queue, and the tick stays a fixed cost whatever a third-party API is doing that evening.

The feature by feature write-up of what Finalist does is at https://novaedgedigitallabs.tech/blog/finalist-esports-platform-guide and the documentation is at https://docs.finalist.live/.

Frequently asked questions

What is Finalist?
Finalist is an esports platform for running scrims and tournaments, at finalist.live. It handles registration, slot lists and waitlists, room details, results and leaderboards, with a player app at play.finalist.live, an organizer dashboard at app.finalist.live and a Discord bot that mirrors events into a server. It is built for battle-royale titles including BGMI, PUBG, Free Fire, Fortnite and Apex Legends.
Why store registration windows as offsets instead of timestamps?
Because organizers run recurring events, and a template holding absolute times drifts. Store minutes before start (360 to open registration, 30 to close it) and moving the start time moves the whole schedule with it, since the start is the only clock value in the record. It also removes a class of daylight saving bug: the preset carries its own timezone, that timezone is applied when each event is stamped out of the template, and every process runs under TZ=UTC so nothing local leaks in.
How do you stop a scheduled worker running the same job twice?
Two mechanisms, because one is not enough. A Redis lock keyed on the event and the transition, held longer than the transition can take, stops two worker replicas advancing the same edge during an overlapping deploy. Separately, destructive transitions record that they have run, which covers the case where a worker dies holding a lock. On Finalist the pass that matters is the pre-match filter: it unregisters teams and backfills their slots from the waitlist, so a second run would take slots off teams it had just promoted.
Does Finalist handle entry fees or prize money?
No, and there is no plumbing for it in the data model. No wallet, no entry-fee field, no payment gateway and no escrow, because India's Promotion and Regulation of Online Gaming Act, 2025 separates esports from online money games. Organizers who charge entry collect it outside the platform. Prizes exist as a record rather than a transfer: bands and payout references logged against the event, paid organizer to team.
Which tournament formats does Finalist support?
Six: battle-royale points tables with lobby rotation, round robin, single elimination, double elimination, Swiss, and gauntlet. A tournament is a list of ordered stages, each with its own format and its own advancement rule, and standings recompute every time a match is confirmed with the qualification cut drawn on the table. Stage configuration is validated when you save the stage rather than on match day.

Written by Amit Kumar Raikwar, full-stack engineer & product designer in Indore, India. If you want something built, start here.

Working on something?

Let's build it together.

I take projects from an empty Figma file to a live product. Fixed scope, weekly demos, code you own.