Pada game endless runner 3D berbasis React Three Fiber, event seperti score tick, coin collect, atau reward claim sering dikirim dari client ke API secara berkala atau saat momen penting terjadi. Masalah muncul ketika jaringan lambat, request timeout, atau client melakukan retry: event yang sama bisa diproses lebih dari sekali jika endpoint backend tidak idempoten. Akibatnya skor pemain berlipat, leaderboard melonjak tidak wajar, dan reward bisa terkirim dua kali.

Artikel ini membahas studi kasus debug backend: skor runner ganda akibat retry event tanpa idempoten. Fokusnya bukan pada teori umum, tetapi pada pola bug yang nyata: gejala di produksi, timeline insiden, log yang menyesatkan, akar masalah di endpoint event, dan langkah perbaikan yang bisa langsung diterapkan oleh developer backend.

Konteks frontend yang relevan: game endless runner 3D berbasis React Three Fiber mengirim event permainan ke backend. Pada arsitektur seperti ini, backend tidak boleh berasumsi bahwa setiap event hanya akan terkirim sekali.

Gambaran sistem dan titik rawan bug

Bayangkan alur sederhananya seperti ini:

  • Client game mengirim event collect saat pemain mengambil item.
  • Client juga mengirim event skor, misalnya per interval atau saat run selesai.
  • Backend menerima event, menambah skor, menyimpan histori event, memperbarui leaderboard, lalu memicu reward.

Secara fungsional terlihat benar. Masalahnya ada pada asumsi tersembunyi: sekali request masuk, sekali efek diterapkan. Di dunia nyata, asumsi ini lemah karena:

  • Jaringan seluler tidak stabil.
  • Client timeout padahal server sebenarnya masih memproses request.
  • Library HTTP atau kode client melakukan retry otomatis.
  • Proxy, gateway, atau load balancer bisa menyebabkan duplicate delivery dalam skenario tertentu.

Jika endpoint POST /events menambah skor setiap kali request diterima tanpa mekanisme deduplikasi, event yang sama akan dianggap sebagai event baru.

Gejala insiden di produksi

Pada kasus ini, tim melihat beberapa gejala yang awalnya tampak tidak saling terkait:

  • Skor pemain tertentu kadang bertambah dua kali lipat.
  • Leaderboard melonjak tiba-tiba untuk sebagian kecil pemain.
  • Reward milestone terkirim dua kali.
  • Keluhan pengguna meningkat saat jaringan sedang buruk.
  • Insiden sulit direproduksi di lingkungan lokal dengan koneksi stabil.

Yang membuat bug ini menjengkelkan adalah sifatnya intermiten. Pada 95% request semuanya normal, tetapi pada kondisi timeout dan retry hasilnya rusak.

Contoh gejala yang tampak di data

Misalnya pemain mengambil 1 coin bernilai 10 poin. Dalam kondisi normal:

score_before = 1200
collect_value = 10
score_after = 1210

Namun pada kondisi duplicate delivery, event yang sama bisa diproses dua kali:

score_before = 1200
collect_value = 10
score_after = 1210

score_before = 1210
collect_value = 10
score_after = 1220

Jika pola ini terjadi pada banyak event selama satu run, selisih skor akhir bisa besar. Bila reward diberikan setiap melewati ambang tertentu, efek turunannya menjadi lebih serius daripada sekadar angka leaderboard.

Timeline insiden: kenapa terlihat seperti bug acak

Berikut contoh timeline yang umum terjadi pada insiden semacam ini:

  1. 10:02 — Monitoring menunjukkan lonjakan skor tidak wajar pada beberapa pemain.
  2. 10:10 — Tim support melaporkan reward dobel.
  3. 10:20 — Pengecekan cepat pada database menunjukkan tidak ada korupsi data global.
  4. 10:35 — Backend log terlihat normal: mayoritas request 200 OK.
  5. 10:50 — Dugaan awal mengarah ke bug frontend atau manipulasi client.
  6. 11:20 — Korelasi ditemukan: kasus banyak terjadi pada pengguna dengan latency tinggi.
  7. 12:00 — Ditemukan pola request yang sama masuk lebih dari sekali dalam rentang detik yang sempit.
  8. 12:30 — Root cause mengarah ke endpoint event yang tidak idempoten.

Timeline ini penting karena banyak tim terjebak pada dugaan yang salah: menganggap penyebabnya cheat, race condition acak di frontend, atau bug leaderboard terpisah. Padahal sumbernya satu: event yang sama diproses berulang.

Cara reproduksi bug secara sengaja

Bug seperti ini harus bisa direproduksi dengan kontrol terhadap timeout dan retry. Tanpa itu, investigasi biasanya berjalan lambat.

1. Simulasikan request lambat

Tambahkan delay artifisial di endpoint staging atau environment lokal untuk meniru kondisi server lambat:

// pseudo-code
async function handleCollect(req, res) {
  await sleep(2500); // simulasi latency
  // proses collect
  res.status(200).json({ ok: true });
}

Lalu set timeout client lebih pendek, misalnya 1 detik. Jika client melakukan retry setelah timeout, request yang sama akan terkirim lagi.

2. Kirim event yang sama dua kali

Gunakan curl atau skrip sederhana untuk mengirim payload identik beberapa kali:

curl -X POST https://api.example.com/events/collect \
  -H "Content-Type: application/json" \
  -d '{
    "playerId": "p123",
    "runId": "run_789",
    "eventId": "evt_abc123",
    "type": "collect",
    "itemId": "coin_gold",
    "scoreDelta": 10,
    "occurredAt": "2026-08-20T10:00:00Z"
  }'

Jika request yang sama diproses dua kali dan skor bertambah dua kali, endpoint jelas belum idempoten.

3. Uji retry otomatis di client

Pada client game, retry sering dilakukan dengan niat baik: jangan sampai progress pemain hilang. Namun jika retry tidak disertai eventId stabil atau idempotency key, backend tidak punya cara membedakan mana event baru dan mana pengiriman ulang.

Pola berbahaya biasanya seperti ini:

  • Client membuat request event collect.
  • Server memproses event, tetapi respons terlambat.
  • Client menganggap request gagal karena timeout.
  • Client mengirim ulang event yang sama.
  • Server memproses ulang karena tidak ada proteksi deduplikasi.

Log yang menyesatkan saat investigasi

Salah satu alasan bug ini sering lolos adalah log terlihat “bersih”. Beberapa jebakan umum:

HTTP 200 tidak berarti aman

Backend mungkin mencatat dua request sukses dengan payload mirip, tetapi jika tidak ada korelasi berdasarkan eventId, itu terlihat seperti dua event normal.

INFO collect accepted player=p123 run=run_789 scoreDelta=10 status=200
INFO collect accepted player=p123 run=run_789 scoreDelta=10 status=200

Tanpa field deduplikasi, log di atas tidak memberi petunjuk bahwa request kedua adalah duplikat.

Timeout di client tidak selalu tampak sebagai error server

Sering kali server selesai memproses event, tetapi client sudah terlanjur timeout. Dari sisi server, semuanya sukses. Dari sisi client, request dianggap gagal dan di-retry. Ini menciptakan ilusi bahwa “retry terjadi setelah gagal”, padahal eksekusi pertama sebenarnya sudah berhasil.

Leaderboard tampak sebagai sumber masalah, padahal hanya efek hilir

Tim kadang fokus pada service leaderboard karena angka di sana terlihat salah. Padahal leaderboard hanya membaca skor yang sudah keburu terduplikasi dari service event atau score aggregation.

Root cause: endpoint event tidak idempoten

Akar masalah utamanya sederhana: endpoint menerima event dan langsung menerapkan efek samping tanpa memastikan bahwa event tersebut belum pernah diproses sebelumnya.

Contoh implementasi rawan:

// pseudo-code
async function collect(req, res) {
  const { playerId, scoreDelta } = req.body;

  await db.query(
    'UPDATE players SET score = score + ? WHERE id = ?',
    [scoreDelta, playerId]
  );

  await db.query(
    'INSERT INTO rewards_log(player_id, reason) VALUES(?, ?)',
    [playerId, 'collect']
  );

  res.json({ ok: true });
}

Kode di atas bermasalah karena:

  • Tidak ada eventId atau idempotency key yang dipakai untuk deduplikasi.
  • Update skor dan pemberian reward langsung dieksekusi pada setiap request masuk.
  • Tidak ada constraint database yang mencegah event sama diproses dua kali.
  • Tidak dibungkus transaksi yang jelas jika ada beberapa operasi turunan.

Secara semantik, endpoint ini adalah at-least-once unsafe. Begitu client atau infrastruktur mengirim ulang request, data menjadi ganda.

Kenapa ini sering terjadi pada game event API

Karena event permainan cenderung banyak, cepat, dan dianggap ringan. Tim sering mengoptimalkan throughput lebih dulu, lalu lupa bahwa untuk event penting seperti skor dan reward, ketepatan efek samping lebih penting daripada sekadar menerima banyak request per detik.

Interaksi retry client, timeout, dan duplicate delivery

Supaya jelas, mari lihat urutan kejadian yang sering memicu bug:

  1. Client mengirim eventId=evt_abc123.
  2. Server mulai memproses dan berhasil menulis perubahan skor.
  3. Respons terlambat karena query lambat, lock, atau network jitter.
  4. Client timeout sebelum menerima respons.
  5. Client melakukan retry dengan payload yang sama.
  6. Server menerima ulang event yang sama dan memprosesnya lagi.

Jika backend tidak idempoten, server tidak bisa membedakan antara:

  • Event baru yang memang harus menambah skor, dan
  • Event lama yang hanya dikirim ulang karena kegagalan komunikasi.

Ini sebabnya solusi harus berada di backend, bukan hanya dengan “mengurangi retry di client”. Retry tetap dibutuhkan untuk keandalan, tetapi backend harus tahan terhadap duplicate delivery.

Perbaikan praktis yang benar-benar menutup bug

1. Gunakan idempotency key atau eventId yang stabil

Setiap event dari client harus memiliki identifier unik yang tetap sama saat retry. Jangan membuat ID baru untuk tiap percobaan kirim. Jika event collect yang sama dikirim ulang, eventId-nya harus identik.

Contoh payload yang lebih aman:

{
  "playerId": "p123",
  "runId": "run_789",
  "eventId": "evt_abc123",
  "type": "collect",
  "itemId": "coin_gold",
  "scoreDelta": 10,
  "occurredAt": "2026-08-20T10:00:00Z"
}

Di backend, simpan eventId tersebut sebelum atau saat menerapkan efek samping. Jika event yang sama datang lagi, kembalikan respons sukses yang aman tanpa memproses ulang.

2. Tambahkan unique constraint di database

Jangan mengandalkan pengecekan di memori saja. Proteksi paling kuat ada di database.

CREATE TABLE game_events (
  id BIGINT PRIMARY KEY,
  player_id VARCHAR(64) NOT NULL,
  run_id VARCHAR(64) NOT NULL,
  event_id VARCHAR(128) NOT NULL,
  event_type VARCHAR(32) NOT NULL,
  score_delta INT NOT NULL,
  occurred_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP NOT NULL,
  UNIQUE (player_id, event_id)
);

Dengan constraint unik, dua request identik tidak bisa sama-sama dianggap event baru. Pilihan kombinasi key bisa berbeda sesuai domain, tetapi prinsipnya sama: satu event logis hanya boleh tercatat sekali.

Trade-off: unique constraint bisa menambah tekanan pada write path, tetapi untuk integritas skor dan reward, biaya ini biasanya layak.

3. Proses event dalam transaksi

Penyimpanan event, update skor, dan pencatatan reward sebaiknya berada dalam satu transaksi agar hasilnya konsisten.

// pseudo-code
async function handleCollect(req, res) {
  const event = req.body;

  await db.transaction(async (tx) => {
    const inserted = await tx.insertIgnore('game_events', {
      player_id: event.playerId,
      run_id: event.runId,
      event_id: event.eventId,
      event_type: event.type,
      score_delta: event.scoreDelta,
      occurred_at: event.occurredAt,
      created_at: new Date()
    });

    if (!inserted) {
      return; // duplikat, jangan proses lagi
    }

    await tx.query(
      'UPDATE players SET score = score + ? WHERE id = ?',
      [event.scoreDelta, event.playerId]
    );

    await tx.query(
      'INSERT INTO score_history(player_id, event_id, delta) VALUES(?, ?, ?)',
      [event.playerId, event.eventId, event.scoreDelta]
    );
  });

  res.json({ ok: true });
}

Pola ini bekerja karena keputusan “sudah pernah diproses atau belum” dibuat di batas penyimpanan yang kuat. Request retry akan gagal memasukkan event duplikat, lalu backend bisa mengembalikan hasil tanpa efek samping kedua.

4. Tambahkan dedup window bila event volume tinggi

Untuk beberapa sistem, selain unique constraint permanen, berguna juga membuat dedup window di Redis atau cache serupa untuk menahan duplikat dalam rentang pendek, misalnya beberapa menit atau jam.

Contoh idenya:

  • Gunakan key seperti dedup:collect:p123:evt_abc123.
  • Set key dengan TTL saat event pertama kali diproses.
  • Jika key sudah ada, anggap event sebagai duplikat.

Namun ini bukan pengganti proteksi database. Cache cocok sebagai lapisan tambahan untuk menurunkan beban, bukan sebagai satu-satunya jaminan konsistensi.

5. Pisahkan event penerimaan dan efek hilir dengan aman

Jika setelah event diterima Anda menerbitkan job queue untuk leaderboard atau reward service, pastikan idempoten juga diterapkan di hilir. Menahan duplikasi di endpoint saja tidak cukup jika downstream consumer masih bisa menggandakan reward.

Praktik yang aman:

  • Simpan event canonical lebih dulu.
  • Terbitkan perubahan berdasarkan event yang sudah tervalidasi unik.
  • Consumer reward juga menyimpan key unik per trigger reward.

6. Kembalikan respons yang aman untuk duplikat

Untuk request duplikat, jangan mengembalikan error generik jika secara bisnis event itu sebenarnya sudah berhasil diproses. Lebih baik kembalikan respons sukses atau status yang jelas bahwa event sudah pernah diterima. Tujuannya agar client tidak makin agresif melakukan retry.

Observability: apa yang harus ditambahkan agar bug cepat terlihat

Insiden duplicate event sering baru terlihat setelah leaderboard rusak. Observability yang tepat akan mempercepat deteksi.

Tambahkan field korelasi di log

  • playerId
  • runId
  • eventId
  • requestId
  • idempotencyKey jika ada
  • hasil deduplikasi: new atau duplicate

Contoh log yang jauh lebih berguna:

INFO event.accepted playerId=p123 runId=run_789 eventId=evt_abc123 dedup=new
INFO event.accepted playerId=p123 runId=run_789 eventId=evt_abc123 dedup=duplicate

Buat metrik khusus

Beberapa metrik yang berguna:

  • jumlah event duplikat per endpoint
  • rasio duplicate terhadap total request
  • jumlah timeout client yang terindikasi
  • jumlah konflik unique constraint
  • jumlah reward trigger per event type

Dengan metrik ini, Anda bisa membedakan antara spike trafik normal dan masalah duplicate delivery.

Trace lintas layanan

Jika ada event service, leaderboard service, dan reward service, gunakan tracing atau setidaknya propagation ID agar satu event bisa diikuti dari pintu masuk hingga efek terakhir. Ini penting untuk memastikan duplikasi benar-benar berhenti di seluruh rantai.

Regression test yang wajib ada

Setelah bug diperbaiki, buat test yang memverifikasi perilaku idempoten. Tanpa regression test, bug ini mudah kembali saat refactor.

Test dasar: request yang sama dua kali hanya menambah skor sekali

// pseudo-test
it('memproses event collect yang sama hanya sekali', async () => {
  const payload = {
    playerId: 'p123',
    runId: 'run_789',
    eventId: 'evt_abc123',
    type: 'collect',
    scoreDelta: 10,
    occurredAt: '2026-08-20T10:00:00Z'
  };

  await api.post('/events/collect', payload);
  await api.post('/events/collect', payload);

  const player = await db.findPlayer('p123');
  expect(player.score).toBe(10);

  const events = await db.findEventsByEventId('evt_abc123');
  expect(events.length).toBe(1);
});

Test konkurensi: dua request identik datang hampir bersamaan

Bug duplikasi sering lolos pada test serial, tetapi gagal pada request paralel. Buat test yang menembakkan dua request identik secara bersamaan, lalu pastikan hanya satu yang benar-benar menghasilkan efek samping.

Test timeout dan retry

Jika memungkinkan, simulasikan skenario request pertama terlambat lalu request kedua dikirim ulang. Ini penting karena kondisi produksi biasanya bukan “dua klik cepat”, melainkan “satu request berhasil tetapi responsnya tidak sampai tepat waktu”.

Kesalahan umum saat memperbaiki bug ini

  • Hanya mematikan retry di client. Ini mengurangi gejala, bukan memperbaiki akar masalah.
  • Mengandalkan pengecekan SELECT lalu INSERT tanpa constraint. Pada kondisi paralel, dua proses bisa sama-sama lolos pengecekan.
  • Menyimpan idempotency key tetapi tidak konsisten antar retry. Kalau key berubah tiap percobaan, deduplikasi tidak berguna.
  • Memperbaiki skor tetapi lupa reward downstream. Efek hilir juga harus idempoten.
  • Menganggap dedup cache cukup. Cache bisa hilang, expire, atau tidak konsisten; database tetap perlu jadi pagar utama.

Strategi implementasi yang pragmatis untuk backend game

Jika Anda ingin solusi yang realistis dan tidak terlalu rumit, urutan berikut biasanya efektif:

  1. Pastikan client mengirim eventId stabil untuk setiap event yang bisa di-retry.
  2. Buat tabel event dengan unique constraint pada key deduplikasi yang tepat.
  3. Proses insert event dan update skor dalam satu transaksi.
  4. Tambahkan status deduplikasi di log dan metrik duplicate rate.
  5. Audit consumer downstream seperti reward dan leaderboard update.
  6. Tulis regression test untuk retry, timeout, dan konkurensi.

Pendekatan ini tidak bergantung pada framework tertentu dan bisa diterapkan di berbagai stack backend selama Anda memiliki kontrol atas model data, transaksi, dan observability.

Penutup

Bug skor runner ganda biasanya bukan masalah matematika skor, melainkan masalah semantik delivery event. Saat client game berbasis React Three Fiber mengirim event skor atau collect ke API, backend harus berasumsi bahwa request bisa datang lebih dari sekali. Retry, timeout, dan duplicate delivery adalah kondisi normal pada sistem terdistribusi, bukan anomali langka.

Karena itu, perbaikannya juga harus sistematis: gunakan idempotency key atau eventId yang stabil, pakai unique constraint, jalankan perubahan dalam transaksi, tambahkan dedup window bila perlu, dan perkuat observability serta regression test. Dengan begitu, Anda tidak hanya menambal leaderboard yang melonjak, tetapi benar-benar menutup kelas bug yang sama di masa depan.