Skip to content
Back to blog

Team Up Blog

Auto-Record CS2 Matches to a Discord Elo Leaderboard with MatchZy

| Team Up | 12 min read

Wire your CS2 pug server straight into a Discord Elo leaderboard. MatchZy posts the result the moment the map ends, a small Cloudflare Worker translates it, and Team Up updates ratings, tier roles, and stats — no admin typing scores.

guideapics2elo

Most CS2 pug communities have the same last mile problem. The server runs the match perfectly, the scoreboard is right there at the end, and then… someone screenshots it, posts it in a channel, and an admin retypes ten names into a bot. Every game. Forever.

If you run your pugs on MatchZy, you don't have to. MatchZy already POSTs every match event to a URL of your choosing. All that's missing is something that turns its payload into a Team Up record_match call. That "something" is about 80 lines of Cloudflare Worker, and this post walks through the whole thing — including the four gotchas in MatchZy's event payloads that will silently corrupt your ratings if you don't know about them.

The end state: the map ends, and by the time players are back in Discord their Elo has moved, tier roles have been re-synced, and the pinned leaderboard embed has already re-rendered.

What You Need

  1. A CS2 server running MatchZy (or Get5 — the event format is the same)
  2. A Team Up leaderboard. If you don't have one, the leaderboard setup guide takes about two minutes
  3. A Team Up API key — run /api_key generate in your Discord server, or create one on the API Keys page in the dashboard
  4. A Cloudflare account for the Worker (the free plan is plenty — this is two subrequests per match)

You do not need to modify MatchZy, install a plugin, or run a database.

One compatibility note before you start. The second API call in this guide, sync_post_match, only accepts keys scoped to a Discord server. If your league is a web league, or a Discord server that's been linked to a web league, your API key is scoped to the web org instead and that call returns 400 — This endpoint only works with Discord integration. Recording still works perfectly; you just drop the sync_post_match call and lose the automatic embed re-render and tier-role sync. Everything else in this post applies unchanged.

How the Pieces Fit

CS2 server (MatchZy)
      │  POST every event → matchzy_remote_log_url
      ▼
Cloudflare Worker (the shim — yours)
      │  POST /record_match       → ratings update
      │  POST /sync_post_match    → embeds, tier roles, audit log
      ▼
Team Up  →  Discord leaderboard

MatchZy sends every event to that one URL — round_start, round_end, map_result, series_end, player_connect, and a dozen more. Your shim filters for the two it cares about and returns 204 for the rest.

Step 1: Decide how players are identified

MatchZy identifies players by Steam64 ID. Team Up players have their own IDs — a Discord user ID for anyone who joined through Discord. You have two ways to bridge that, and the right choice depends on how much you care about tier roles.

Option A: let Team Up resolve Steam IDs (less setup)

record_match accepts an external identity in place of a player ID:

{ "team": [{ "provider": "steam", "id": "76561198012345678" }], "place": 1 }

Team Up resolves it in this order: an identity it has seen before maps to the same player; otherwise a player whose profile already lists that Steam ID is matched; otherwise a new player is created and returned to you in created_players.

Step two is what keeps people from appearing twice — if your members add their Steam ID to their Team Up profile, they're matched to their existing rating automatically, with no mapping file anywhere. That's worth a one-time announcement in your Discord.

The catch: a player Team Up only knows by Steam ID has no Discord account attached, so tier roles are skipped for them until someone links the accounts. Ratings, stats, and leaderboard placement all work normally.

Option B: map Steam IDs to Discord IDs yourself (full Discord integration)

If you want tier roles working from the first match, keep a lookup table so the shim always sends Discord user IDs. That's what the rest of this post does, because it exercises more of the integration.

The simplest version is a JSON file you commit next to the Worker (to use Option A instead, delete players.json and have rosterAndStats emit { provider: 'steam', id: player.steamid } objects rather than looking IDs up):

{
  "76561198012345678": "319876543210987654",
  "76561198087654321": "412345678901234567",
  "76561197960287930": "298765432109876543"
}

Left side Steam64, right side Discord user ID. To get Discord IDs, turn on Developer Mode in Discord (Settings → Advanced) and right-click a user → Copy User ID.

For a stable pug community of 30–60 regulars this file is genuinely fine — you edit it when someone new joins and never think about it again.

A hybrid works nicely too: send Discord IDs for everyone in players.json, and fall back to { provider: 'steam', id } for anyone who isn't in it. Regulars get tier roles, newcomers still get recorded instead of failing the match, and created_players in the response tells you exactly who to add.

Step 2: The Worker

Create the Worker and a KV namespace (the KV is for Step 4; wire it now so you don't have to redeploy):

npm create cloudflare@latest matchzy-teamup
cd matchzy-teamup
npx wrangler kv namespace create MATCH_CACHE

wrangler.toml:

name = "matchzy-teamup"
main = "src/index.js"
compatibility_date = "2026-07-01"

[vars]
LEADERBOARD = "pugs"                       # your Team Up leaderboard name
TEAM_SIZE = "5"                            # refuse to record anything that isn't 5v5
RECORDER_DISCORD_ID = "YOUR_DISCORD_USER_ID" # shows as "recorded by" in the audit log

[[kv_namespaces]]
binding = "MATCH_CACHE"
id = "<the id wrangler printed>"

Then the two secrets:

npx wrangler secret put TEAMUP_API_KEY   # from /api_key generate
npx wrangler secret put MATCHZY_TOKEN    # any long random string you make up

src/index.js:

import PLAYERS from './players.json';

const TEAMUP_API = 'https://api.teamupgg.com';

const STAT_DEFINITIONS = [
    { name: 'score',   label: 'Score',   scope: 'match',  type: 'string', aggregates: ['count'] },
    { name: 'kills',   label: 'Kills',   scope: 'player', type: 'number', aggregates: ['sum', 'avg', 'max'] },
    { name: 'deaths',  label: 'Deaths',  scope: 'player', type: 'number', aggregates: ['sum', 'avg'] },
    { name: 'assists', label: 'Assists', scope: 'player', type: 'number', aggregates: ['sum', 'avg'] },
];

export default {
    async fetch(request, env) {
        if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });
        if (request.headers.get('X-MatchZy-Token') !== env.MATCHZY_TOKEN) {
            return new Response('Unauthorized', { status: 401 });
        }

        const event = await request.json();

        // map_result arrives with EMPTY player arrays (see "Gotchas" below), so
        // stash each round_end — the last one before map end has the final
        // rosters and stats.
        if (event.event === 'round_end') {
            await env.MATCH_CACHE.put(
                `rounds:${event.matchid}:${event.map_number}`,
                JSON.stringify({ team1: event.team1, team2: event.team2 }),
                { expirationTtl: 60 * 60 * 6 },
            );
            return new Response(null, { status: 204 });
        }

        // Every other event type gets acknowledged and dropped.
        if (event.event !== 'map_result') return new Response(null, { status: 204 });

        try {
            const summary = await recordMap(event, env);
            return new Response(summary, { status: 200 });
        } catch (err) {
            // MatchZy logs the status code to the server console and moves on,
            // so put something diagnosable in the body.
            console.error(`[matchzy] match ${event.matchid} map ${event.map_number}: ${err.message}`);
            return new Response(err.message, { status: 500 });
        }
    },
};

function rosterAndStats(team) {
    const ids = [];
    const stats = {};
    for (const player of team?.players ?? []) {
        const discordId = PLAYERS[player.steamid];
        if (!discordId) throw new Error(`unmapped steamid ${player.steamid} (${player.name})`);
        ids.push(discordId);
        stats[discordId] = {
            kills: player.stats.kills,
            deaths: player.stats.deaths,
            assists: player.stats.assists,
        };
    }
    if (!ids.length) {
        throw new Error(`no players resolved for team ${team?.name} — round_end cache miss?`);
    }
    return { ids, stats };
}

async function recordMap(event, env) {
    const cached = await env.MATCH_CACHE.get(`rounds:${event.matchid}:${event.map_number}`, 'json');
    const team1 = rosterAndStats(cached?.team1 ?? event.team1);
    const team2 = rosterAndStats(cached?.team2 ?? event.team2);

    // Don't trust `event.winner.team`. MatchZy computes it as
    // `t1score > t2score ? "team1" : "team2"`, so a 15-15 draw is reported as a
    // team2 win. Compare the scores yourself; equal places = a draw to Team Up.
    const s1 = event.team1.score;
    const s2 = event.team2.score;

    const matchResults = [
        { team: team1.ids, place: s1 >= s2 ? 1 : 2 },
        { team: team2.ids, place: s2 >= s1 ? 1 : 2 },
    ];

    // Coaches appear to come through in the team player lists (see "Gotchas"),
    // so refuse anything that isn't the roster size you expect rather than
    // silently recording a 6v5.
    const expected = Number(env.TEAM_SIZE ?? 5);
    if (team1.ids.length !== expected || team2.ids.length !== expected) {
        throw new Error(`unexpected roster ${team1.ids.length}v${team2.ids.length}, expected ${expected}v${expected}`);
    }

    const recorded = await teamup(env, 'record_match', {
        leaderboard: env.LEADERBOARD,
        matchResults,
        // Idempotency. record_match atomically claims this string before it
        // writes anything, so a replayed map end comes back 409 instead of
        // applying Elo twice. It never has to be a real Discord message id —
        // omitting discord_channel_id means nothing tries to call Discord with
        // it. undo_last_match releases the claim, so re-recording still works.
        discord_message_id: `matchzy-${event.matchid}-${event.map_number}`,
        match_stats: { score: `${s1}-${s2}` },
        player_stats: { ...team1.stats, ...team2.stats },
        stat_definitions: STAT_DEFINITIONS,
    });
    if (recorded.duplicate) return `already recorded, skipped`;

    // record_match ONLY moves ratings. This second call is what re-renders the
    // pinned leaderboard embeds, syncs tier roles, and writes the audit log.
    // Note the field name — `teams`, not `matchResults`.
    await teamup(env, 'sync_post_match', {
        leaderboard: env.LEADERBOARD,
        teams: matchResults.map((r) => r.team),
        audit_log: true,
        source: 'api',
        recorded_by: env.RECORDER_DISCORD_ID,
        match_results: matchResults,
    });

    return `recorded match #${recorded.match_number} (${s1}-${s2})`;
}

async function teamup(env, action, body) {
    const res = await fetch(`${TEAMUP_API}/${action}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-API-Key': env.TEAMUP_API_KEY },
        body: JSON.stringify(body),
    });
    const json = await res.json().catch(() => ({}));
    // 409 = the idempotency claim already exists. Not an error — the match is
    // already on the board, so let the caller short-circuit quietly.
    if (res.status === 409) return { ...json, duplicate: true };
    if (!res.ok) throw new Error(`${action} → ${res.status} ${json.error ?? json.message ?? ''}`);
    return json;
}

Deploy it:

npx wrangler deploy

Step 3: Point MatchZy at It

Two cvars in your server.cfg (or matchzy.cfg) and you're done:

matchzy_remote_log_url "https://matchzy-teamup.<your-subdomain>.workers.dev"
matchzy_remote_log_header_key "X-MatchZy-Token"
matchzy_remote_log_header_value "<the MATCHZY_TOKEN secret you set>"

Every one of these also exists under a get5_ prefix (get5_remote_log_url, etc.), so an existing Get5 config works unchanged.

To verify the wiring, start a tail on the Worker and then start any match:

npx wrangler tail

You don't need to play a full map. series_start fires the moment a match goes live, and round_end fires after every single round — both will show up in the tail and both will return 204 (they aren't map_result), which is exactly right. You're confirming the request arrives and the token passes.

MatchZy also logs every send to the game server console, including the status code it got back:

[MatchZy] [SendEventAsync] Sending Event: round_end for matchId: 1 ... on https://...
[MatchZy] [SendEventAsync] Sending round_end ... successful with status code: NoContent

That line is your fastest diagnostic. If nothing appears in either place, the usual causes are a missing protocol on the URL or the game server blocking outbound HTTPS. If it appears in the server console with a Unauthorized status, your header key and value don't match the MATCHZY_TOKEN secret.

(Some MatchZy forks add a css_testevent command for exactly this. Upstream MatchZy doesn't have one — if css_testevent comes back as an unknown command, that's why.)

Step 4: What You Get

Play a map. When it ends:

  • Elo updates for all ten players, calculated from their current ratings — winning against a stacked team moves you more than beating a team of new accounts
  • Kills / deaths / assists land on each player's profile, aggregating into totals, averages, and a personal best for kills
  • Tier roles get granted and revoked in Discord for anyone who crossed a threshold
  • Pinned leaderboard and match-history embeds re-render with the new standings
  • The audit log records the match with (via API) on it, so you can tell automated results from manually-entered ones

Players see /mystats and their public profile update with no admin involvement at all.

If you want stats to actually influence rating — reward the guy who dropped 30 in a loss — Team Up supports a stat-points mode where number stats with a points multiplier feed into the Elo delta. That's covered in the custom stats guide.

Gotchas

These are the things that cost real debugging time. All four are properties of MatchZy's event payloads rather than anything you'd guess from the docs.

map_result ships empty player arrays

This is the big one. The map_result event has team1.players and team2.players fields, and they are always empty arrays — MatchZy constructs the event with an empty player list and only fills those arrays on round_end events. If you build the obvious integration that reads rosters off map_result, you get zero players and a confusing 400 back from record_match.

That's the entire reason the shim above caches round_end. The final round_end of a map carries the finished rosters and stats; map_result carries the authoritative final score. You need both.

A draw is reported as a team2 win

map_result.winner.team is computed as t1score > t2score ? "team1" : "team2". There's no third branch, so a 15-15 draw comes through as a team2 win and you'd hand out a full Elo swing for a tied match.

Compare team1.score and team2.score yourself, and give both teams place: 1 when they're equal — Team Up reads equal placements as a draw and applies the half-point Elo result. (series_end gets this right, reporting winner.team: "none", but a single-map pug is decided at map_result.)

A pug fires both map_result and series_end

When no match config is loaded — the normal pug/scrim case — MatchZy treats the map as a one-map series and immediately ends the series after the map. You get map_result and series_end back to back. Record on both and every pug gets counted twice.

Pick one:

  • Recording per map (map_result) is right for pugs and for BO3 leagues where each map should move ratings independently. That's what this post does.
  • Recording per series (series_end) is right if a BO3 should count as one win. Note that series_end has no player rosters at all — only team1_series_score / team2_series_score — so you'd cache rosters from round_end the same way.

A .stop/backup restore can also replay a map end, which is the other half of the same problem. Both are handled by the discord_message_id idempotency key rather than by anything in the shim — see below.

MatchZy fires and forgets, so put idempotency server-side

Events are sent with no retry. A non-2xx response is written to the game server console and then dropped — the match result is gone. So:

  • Return 2xx fast and don't do anything slow inline
  • Log loudly, and put a real message in the response body (the status code is the only thing that reaches the server console)
  • Keep /undo_last_match in your back pocket for when something does slip through wrong

Don't build the duplicate guard in KV. The obvious design — write a done: key after recording, check it before — is unsound, because Workers KV caches negative lookups: a read that found nothing is remembered locally, so a replayed map_result can read a stale "not recorded" and record the match a second time. That's precisely the case the guard exists to prevent.

Push it into record_match instead. Passing discord_message_id makes it atomically claim that string with a conditional write before it touches any rating, returning 409 on the second attempt. It's strongly consistent, survives a shim redeploy or a KV eviction, and it's the same guard the Discord bot uses. The value doesn't have to be a real message ID — matchzy-{matchid}-{map_number} is fine, and as long as you omit discord_channel_id nothing will ever try to call Discord with it.

The rounds: cache is still KV, and that's fine: the same game server writes and reads it from the same Cloudflare location seconds apart, where writes are immediately visible. Just don't lean on KV for correctness across a longer gap.

Coaches are probably counted as players

MatchZy builds the per-round player lists by filtering on team number alone. Elsewhere in the plugin — the alive-player count, for instance — there's an explicit coach.Contains(player) exclusion, and that exclusion is not present in the stats path. So a match played with coaches will likely hand you a six-person "team" and your shim will cheerfully try to record a 6v5.

That's why the shim above hard-fails on any roster that isn't your expected team size. Losing one match to a thrown error is much better than quietly putting a coach on the leaderboard with a full Elo swing. If you run coached matches regularly, keep your coaches' Steam IDs in a skip list and filter them out before building the roster.

One smaller note while you're in here: map_result doesn't include the map name. map_name only appears on map_picked, map_vetoed, and side_picked. If you run a veto, cache the map_picked event to get it; if you're running plain pugs with no veto, there's no map name in the event stream at all and you'll want to pull it from your own match config.

Frequently Asked Questions

Does this work with Get5 instead of MatchZy?

Yes. MatchZy implements the Get5 event format and accepts get5_remote_log_url, get5_remote_log_header_key, and get5_remote_log_header_value as aliases. An existing Get5 server config needs no changes.

Do I have to use Cloudflare Workers?

No. The shim is an ordinary HTTP endpoint — anything that can receive a POST works, including a small Express app or a serverless function on any provider. Workers is convenient because it's free at this volume, has no cold start, and KV gives you the round-cache without running a database.

What happens if a player isn't in my Steam-to-Discord map?

The shim above throws and records nothing. That's deliberate: silently dropping a player turns a 5v5 into a 4v5, which changes how Team Up groups the ratings and produces wrong Elo for everyone in the match. The failing Steam ID is in the error message, so you add it to players.json, redeploy, and re-record that match manually.

If you'd rather never lose a match to this, emit { provider: 'steam', id: player.steamid } for unmapped players instead of throwing — Team Up will resolve or create them, and list any new ones in created_players.

Can I record 2v2 or 1v1 wingman matches the same way?

Yes. Nothing in the shim assumes five players a side — it reads whatever rosters MatchZy reports. Team Up creates separate rating groups per format, so 5v5 and 2v2 results on the same leaderboard get their own rating tracks automatically.

Will this double-count if the server crashes and restores a round?

No. The discord_message_id we send is derived from MatchZy's matchid and map_number, and record_match claims it with a conditional write before applying any Elo — so a replay comes back 409 and the shim skips it. A genuinely new match gets a new matchid, so back-to-back games are never blocked. Undoing a match releases the claim, so a legitimate re-record works too.

How many API calls does each match use?

Two — one record_match and one sync_post_match. The round_end caching is KV only and never touches the Team Up API. Note that recording is separately capped per tier: the free tier allows 50 matches per leaderboard per day, and going over returns 429 with code TIER_LIMIT_MATCHES. The shim treats that as a failure and the result is lost, so a very busy league should either watch for it or upgrade.

Can I skip the second API call?

Only if you don't use any of Team Up's Discord integrations. record_match updates ratings and nothing else — it deliberately doesn't touch Discord. If you have pinned leaderboard embeds, tier roles, or an audit channel, sync_post_match is what refreshes them. Watch the field name when you write it: it takes teams (a flat array of team arrays), not the matchResults you sent to record_match. That mismatch is the single most common source of 400s on this endpoint.


Full endpoint reference lives in the API docs. Questions, or want to show off your setup? Come by our Discord.