Dalam sistem game kecil, masalah pada save game sering bukan karena logika gameplay, melainkan karena integrasi antar layanan. Saat aksi pemain memicu sinkronisasi ke layanan lain lewat webhook—misalnya menyimpan progres, item, atau pencapaian—Anda harus menganggap bahwa event bisa dikirim ulang, datang tidak berurutan, atau gagal diproses di percobaan pertama.

Desain webhook untuk save game yang baik harus memenuhi tiga hal utama: aman di-retry, tahan duplicate delivery, dan tetap benar meski event out-of-order. Jika tiga hal ini diabaikan, bug yang muncul biasanya konkret: item dobel, quest lompat status, save lama menimpa save baru, atau layanan downstream menyimpan progres yang tidak lagi valid.

Untuk membuat pembahasan lebih nyata, kita pakai konteks game kecil seperti Letters to Tomorrow: pemain menulis surat, mengumpulkan kenang-kenangan, dan memilih apa yang dibawa ke hari esok. Setiap aksi penting di game backend bisa memicu event ke layanan lain, misalnya analytics, inventory snapshot, cloud save, atau layanan notifikasi.

Arsitektur Dasar: Pisahkan Penerimaan Event dari Pemrosesan

Salah satu kesalahan paling umum adalah memproses seluruh bisnis logic langsung di endpoint webhook. Ini berbahaya karena:

  • pengirim bisa timeout lalu me-retry walau server Anda sebenarnya sedang bekerja,
  • pekerjaan yang berat memperbesar peluang duplicate delivery,
  • sulit membedakan apakah request gagal di jaringan, gagal validasi, atau gagal saat menyimpan data.

Pola yang lebih aman:

  1. Terima HTTP request webhook.
  2. Verifikasi signature dan validasi payload minimum.
  3. Simpan event mentah ke event log.
  4. Catat status penerimaan.
  5. Balas cepat dengan status code yang tepat.
  6. Proses event secara asynchronous lewat queue/worker.

Dengan pola ini, endpoint webhook hanya bertugas sebagai durable ingress. Anda tidak perlu menyelesaikan seluruh update inventory atau save progress sebelum membalas request.

Contoh alur

Misalnya pemain menyelesaikan babak dan backend game mengirim event progress.saved ke layanan cloud save:

  • Game service mengirim webhook.
  • Webhook receiver memverifikasi signature.
  • Receiver menyimpan payload mentah dan metadata ke tabel event log.
  • Receiver mengembalikan 202 Accepted atau 200 OK sesuai kontrak integrasi.
  • Worker mengambil event dari queue lalu memprosesnya.
  • Worker menerapkan idempotensi dan aturan urutan event sebelum mengubah state pemain.

Kontrak Payload: Jangan Kirim Hanya Data “Yang Kelihatan Perlu”

Payload webhook untuk save game harus memuat cukup informasi agar penerima bisa:

  • mengidentifikasi pemain dan sesi,
  • mendeteksi duplikasi,
  • menentukan urutan event,
  • memverifikasi integritas request,
  • melakukan debugging saat ada bug produksi.

Field minimal yang disarankan

  • event_id: ID unik per event, bukan per pemain.
  • event_type: misalnya progress.saved, inventory.updated.
  • occurred_at: waktu event dibuat di sisi pengirim.
  • player_id: identitas pemain yang stabil.
  • save_slot: jika game mendukung beberapa slot save.
  • sequence: nomor urut logis per pemain/slot atau per aggregate.
  • idempotency_key: kunci deduplikasi yang stabil untuk aksi yang sama.
  • payload_version: versi kontrak payload.
  • data: isi domain event, misalnya chapter, inventory delta, snapshot progress.

Catatan penting: event_id dan idempotency_key tidak selalu sama. event_id unik untuk setiap kiriman event. idempotency_key mewakili satu operasi logis yang tidak boleh diterapkan dua kali. Jika pengirim me-retry request yang sama, event_id bisa sama atau berbeda tergantung desain sistem, tetapi idempotency_key idealnya tetap sama untuk operasi yang sama.

Contoh JSON payload

{
  "event_id": "evt_01JX9M8Y6G4N3T2P1K7R",
  "event_type": "inventory.updated",
  "payload_version": 1,
  "occurred_at": "2026-06-28T10:15:21Z",
  "player_id": "player_1024",
  "save_slot": "slot_a",
  "sequence": 184,
  "idempotency_key": "player_1024:slot_a:inventory:chapter3_reward",
  "data": {
    "reason": "chapter_reward",
    "chapter": 3,
    "added_items": [
      { "item_id": "pressed_flower", "qty": 1 },
      { "item_id": "old_stamp", "qty": 2 }
    ],
    "inventory_revision": 57
  }
}

Field inventory_revision berguna jika Anda ingin mendeteksi update lama yang berusaha menimpa state lebih baru. Dalam banyak kasus, kombinasi player_id + save_slot + sequence sudah cukup untuk kontrol urutan.

Signature Verification: Pastikan Request Memang dari Pengirim yang Sah

Webhook tanpa verifikasi signature hanya mengandalkan kerahasiaan URL, dan itu lemah. Endpoint bisa dipanggil pihak lain, payload bisa dimodifikasi di transit internal, atau request replay bisa dilakukan jika tidak ada validasi waktu.

Pola verifikasi yang umum

  • Pengirim dan penerima berbagi shared secret.
  • Pengirim membuat HMAC dari body mentah request.
  • Signature dikirim lewat header, misalnya X-Signature.
  • Penerima menghitung ulang HMAC dari body mentah dan membandingkannya dengan constant-time comparison.

Jika memungkinkan, sertakan juga timestamp di header, lalu signature dihitung dari gabungan timestamp dan raw body. Ini membantu mencegah replay attack di luar jendela waktu yang diizinkan.

Pseudocode verifikasi signature

function verifySignature(rawBody, signatureHeader, timestampHeader, secret):
    if timestampHeader is missing:
        return false

    if abs(now() - parseTime(timestampHeader)) > allowedSkew:
        return false

    signedPayload = timestampHeader + "." + rawBody
    expected = hmac_sha256(secret, signedPayload)

    return constantTimeEquals(expected, signatureHeader)

Kesalahan umum:

  • menghitung HMAC dari JSON yang sudah di-parse lalu diserialisasi ulang, padahal whitespace atau urutan field bisa berubah,
  • membandingkan signature dengan operator string biasa,
  • tidak memvalidasi timestamp, sehingga request valid lama bisa di-replay,
  • mencatat secret atau signature lengkap ke log aplikasi.

Idempotensi: Kunci Utama agar Retry Tidak Menyebabkan Save Ganda

Dalam sistem webhook, duplicate delivery adalah kondisi normal, bukan anomali. Pengirim bisa me-retry karena timeout, koneksi putus setelah server menerima request, atau karena tidak sempat membaca response.

Karena itu, handler harus idempotent: menerapkan event yang sama berkali-kali tetap menghasilkan state akhir yang sama.

Apa yang perlu di-idempotent-kan?

Bukan hanya endpoint penerimaan, tetapi juga efek bisnisnya:

  • penambahan item inventory,
  • pembaruan chapter terakhir,
  • penyimpanan checkpoint,
  • pengiriman reward ke layanan lain,
  • pencatatan achievement.

Strategi idempotensi yang praktis

  1. Simpan idempotency key di tabel terpisah atau langsung di event log.
  2. Beri unique constraint pada kombinasi yang merepresentasikan satu operasi logis.
  3. Lakukan cek dan insert dalam satu transaksi.
  4. Jika key sudah pernah diproses, kembalikan hasil aman tanpa mengulangi efek samping.

Skema tabel minimal

-- event mentah yang diterima dari webhook
CREATE TABLE inbound_webhook_events (
  id BIGINT PRIMARY KEY,
  provider VARCHAR(50) NOT NULL,
  event_id VARCHAR(100) NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  player_id VARCHAR(100),
  save_slot VARCHAR(50),
  sequence_num BIGINT,
  idempotency_key VARCHAR(255),
  signature_valid BOOLEAN NOT NULL,
  received_at TIMESTAMP NOT NULL,
  payload_json TEXT NOT NULL,
  processing_status VARCHAR(30) NOT NULL,
  error_message TEXT NULL
);

CREATE UNIQUE INDEX uq_inbound_provider_event_id
  ON inbound_webhook_events(provider, event_id);

CREATE UNIQUE INDEX uq_inbound_provider_idempotency_key
  ON inbound_webhook_events(provider, idempotency_key);

-- state save game yang telah diproyeksikan
CREATE TABLE player_save_state (
  player_id VARCHAR(100) NOT NULL,
  save_slot VARCHAR(50) NOT NULL,
  last_sequence_num BIGINT NOT NULL DEFAULT 0,
  progress_json TEXT NOT NULL,
  inventory_json TEXT NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  PRIMARY KEY (player_id, save_slot)
);

Jika sistem Anda menerima beberapa jenis event yang memang boleh memakai idempotency key sama pada domain berbeda, pertimbangkan unique key yang lebih spesifik, misalnya (provider, event_type, idempotency_key). Pilihan ini tergantung kontrak pengirim.

Pseudocode handler dengan idempotensi

function handleWebhook(request):
    rawBody = request.rawBody
    signature = request.headers["X-Signature"]
    timestamp = request.headers["X-Timestamp"]

    if !verifySignature(rawBody, signature, timestamp, WEBHOOK_SECRET):
        return response(401, "invalid signature")

    payload = parseJson(rawBody)
    if !isValidPayload(payload):
        return response(400, "invalid payload")

    try:
        beginTransaction()

        inserted = insertInboundEventIfNotExists({
            provider: "game-service",
            event_id: payload.event_id,
            event_type: payload.event_type,
            player_id: payload.player_id,
            save_slot: payload.save_slot,
            sequence_num: payload.sequence,
            idempotency_key: payload.idempotency_key,
            signature_valid: true,
            received_at: now(),
            payload_json: rawBody,
            processing_status: "received"
        })

        commit()
    catch uniqueConstraintViolation:
        rollback()
        return response(200, "duplicate accepted")
    catch:
        rollback()
        return response(500, "temporary error")

    enqueueJob("process_inbound_event", payload.event_id)
    return response(202, "accepted")

Di tahap ini, duplicate request tidak memicu efek bisnis ganda karena event yang sama tidak akan masuk dua kali ke jalur pemrosesan normal.

Order Event: Menghadapi Event yang Datang Tidak Berurutan

Masalah urutan event biasanya muncul saat sistem memproses request secara paralel atau saat retry membuat event lama tiba setelah event baru. Untuk save game, ini sangat berbahaya karena update lama bisa menimpa progres terbaru.

Contoh bug nyata

Pemain di Letters to Tomorrow menyelesaikan dua langkah cepat:

  1. Mengambil item pressed_flower pada sequence 184.
  2. Menyelesaikan refleksi akhir chapter pada sequence 185.

Event sequence 185 diproses lebih dulu dan menyimpan state terbaru. Lalu event sequence 184 yang terlambat datang memuat snapshot inventory lama. Jika handler Anda hanya melakukan UPDATE player_save_state SET progress_json = ... tanpa memeriksa urutan, save terbaru bisa ditimpa state lama.

Strategi menangani out-of-order event

  • Gunakan sequence number per aggregate, misalnya per player_id + save_slot.
  • Simpan last processed sequence di state pemain.
  • Tolak atau tahan event lama yang sequence-nya lebih kecil dari yang sudah diterapkan.
  • Definisikan apakah event berupa snapshot atau delta, karena strategi penanganannya berbeda.

Snapshot vs delta

Snapshot event membawa state lengkap atau sebagian besar state. Jika snapshot lama datang belakangan, ia berbahaya karena bisa menimpa state baru.

Delta event membawa perubahan inkremental, misalnya tambah item +1. Delta lebih kecil, tetapi tetap butuh idempotensi agar tidak diterapkan dua kali.

Untuk save game, banyak tim memilih kombinasi:

  • event operasional berbentuk delta untuk aksi real-time,
  • snapshot periodik untuk rekonsiliasi atau pemulihan.

Pseudocode worker untuk kontrol urutan

function processInboundEvent(eventRecord):
    payload = parseJson(eventRecord.payload_json)

    beginTransaction()

    state = selectForUpdatePlayerState(payload.player_id, payload.save_slot)
    if state not found:
        state = createInitialState(payload.player_id, payload.save_slot)

    if payload.sequence <= state.last_sequence_num:
        markEventProcessed(eventRecord.id, "ignored_outdated")
        commit()
        return

    applyDomainEvent(state, payload)
    state.last_sequence_num = payload.sequence
    savePlayerState(state)

    markEventProcessed(eventRecord.id, "processed")
    commit()

SELECT ... FOR UPDATE atau mekanisme locking setara membantu mencegah dua worker memproses event untuk pemain yang sama secara bersamaan dengan hasil balapan data.

Jika beban tinggi dan banyak event untuk pemain yang sama, pertimbangkan partitioning queue berdasarkan player_id agar event untuk satu pemain cenderung diproses serial. Ini mengurangi konflik lock, tetapi trade-off-nya adalah throughput global bisa lebih sulit diseimbangkan.

Retry dan Backoff: Gagal Sekali Bukan Berarti Gagal Total

Retry harus ada, tetapi retry yang agresif bisa memperparah masalah. Jika layanan penerima lambat, ribuan retry bersamaan justru menciptakan retry storm.

Prinsip retry yang aman

  • Retry hanya untuk error yang bersifat sementara.
  • Gunakan exponential backoff.
  • Tambahkan jitter agar banyak worker tidak me-retry di detik yang sama.
  • Batasi jumlah percobaan maksimal.
  • Kirim event ke dead-letter queue atau tandai untuk investigasi jika gagal permanen.

Status code yang tepat

Untuk webhook receiver, gunakan status code dengan arti yang jelas:

  • 200 OK: request valid dan dianggap diterima; aman juga untuk duplicate yang tidak perlu diproses ulang.
  • 202 Accepted: request valid dan sudah diterima untuk diproses asynchronous.
  • 400 Bad Request: payload salah format atau field wajib hilang; biasanya jangan retry.
  • 401 Unauthorized atau 403 Forbidden: signature/otorisasi gagal; biasanya jangan retry sebelum konfigurasi diperbaiki.
  • 409 Conflict: kadang dipakai untuk konflik urutan atau state, tetapi perlu kontrak yang jelas; banyak integrasi lebih aman menyerap kasus duplicate dengan 200.
  • 429 Too Many Requests: receiver sedang membatasi traffic; pengirim sebaiknya menunda retry.
  • 500, 502, 503, 504: gangguan sementara; pengirim boleh retry dengan backoff.

Dalam praktik, jangan mengembalikan 500 untuk duplicate delivery. Jika event sudah pernah diterima dan hasil akhirnya aman, balas sukses agar pengirim berhenti me-retry.

Pseudocode retry dengan backoff

function nextRetryDelay(attempt):
    base = min(60, 2 ^ attempt)
    jitter = randomBetween(0, 1000) milliseconds
    return base seconds + jitter

Nilai detail retry bergantung pada SLA dan karakter gameplay Anda. Yang penting adalah polanya: makin sering gagal, jarak retry makin panjang.

Event Log: Fondasi untuk Audit, Replay, dan Debugging

Menyimpan hanya state akhir tidak cukup. Saat bug terjadi, Anda perlu tahu event apa yang datang, kapan datang, signature valid atau tidak, status pemrosesannya apa, dan error apa yang muncul.

Apa yang perlu disimpan di event log

  • provider atau sumber event,
  • event_id, event_type, idempotency_key,
  • player_id, save_slot, sequence,
  • raw payload atau salinan payload yang dapat diaudit,
  • waktu diterima,
  • hasil verifikasi signature,
  • status pemrosesan: received, processed, ignored_outdated, failed,
  • pesan error ringkas,
  • jumlah attempt pemrosesan.

Dengan event log, Anda bisa:

  • melakukan replay untuk event yang gagal setelah bug diperbaiki,
  • membuktikan bahwa duplicate datang dari pengirim, bukan dari bug SQL Anda,
  • mengaudit mengapa inventory pemain menjadi dobel atau hilang,
  • membangun dashboard kesehatan integrasi.

Trade-off penyimpanan payload mentah

Menyimpan raw payload mempermudah debugging, tetapi ada biaya storage dan pertimbangan privasi. Jika payload mengandung data sensitif, pertimbangkan:

  • masking field tertentu saat disalin ke log analitik,
  • membatasi retensi event log mentah,
  • memisahkan tabel audit dari tabel operasional,
  • enkripsi at-rest sesuai kebijakan sistem Anda.

Contoh Bug Nyata: Inventory Tersimpan Ganda karena Retry dan Delta Non-Idempotent

Anggap event berikut dikirim saat pemain menerima reward chapter:

{
  "event_id": "evt_a1",
  "event_type": "inventory.updated",
  "player_id": "player_1024",
  "save_slot": "slot_a",
  "sequence": 184,
  "idempotency_key": "player_1024:slot_a:inventory:chapter3_reward",
  "data": {
    "added_items": [
      { "item_id": "old_stamp", "qty": 2 }
    ]
  }
}

Server menerima request, menambah item ke database, tetapi timeout sebelum mengirim response. Pengirim mengira gagal lalu me-retry. Handler naif membaca payload yang sama dan menambah lagi old_stamp sebanyak 2. Hasil akhir: pemain mendapat 4 item, padahal seharusnya 2.

Akar masalah

  • update inventory memakai operasi delta,
  • tidak ada idempotency key yang dicek secara atomik,
  • response timeout dianggap sebagai kegagalan pemrosesan penuh,
  • tidak ada event log untuk membuktikan request pertama sebenarnya sudah masuk.

Perbaikannya

  • simpan idempotency_key sebelum menerapkan efek bisnis,
  • buat unique constraint agar request yang sama tidak lolos dua kali,
  • balas sukses untuk duplicate yang sudah diketahui,
  • jika memungkinkan, ubah sebagian operasi menjadi set-based update atau snapshot revision-aware, bukan sekadar increment buta.

Checklist Observability untuk Webhook Save Game

Jika sistem ini masuk produksi tanpa observability, Anda akan kesulitan membedakan bug gameplay dari bug integrasi.

Metrics yang berguna

  • jumlah request webhook per event_type,
  • rasio signature failure,
  • rasio payload invalid,
  • jumlah duplicate delivery,
  • latensi penerimaan webhook,
  • waktu dari received ke processed,
  • jumlah event out-of-order,
  • retry count per worker/job,
  • jumlah event gagal permanen atau masuk dead-letter queue.

Log yang perlu ada

  • event_id, idempotency_key, player_id, sequence,
  • status verifikasi signature,
  • hasil deduplikasi: baru atau duplicate,
  • decision urutan: diproses, diabaikan, atau ditunda,
  • exception ringkas dan kode error internal.

Tracing yang membantu

Jika Anda memakai distributed tracing, hubungkan request webhook dengan job queue dan operasi database. Ini sangat membantu saat response HTTP sukses, tetapi worker gagal di belakang layar.

Kesalahan Umum yang Membuat Progress atau Inventory Tersimpan Ganda

  • Menganggap webhook hanya dikirim sekali. Dalam praktik, duplicate delivery harus diasumsikan normal.
  • Tidak punya unique constraint. Cek duplicate di level aplikasi saja rawan race condition.
  • Menggunakan timestamp sebagai satu-satunya pengurut. Jam antar layanan bisa bergeser; sequence logis lebih andal.
  • Memproses webhook sinkron dan lama. Ini memicu timeout dan retry yang tidak perlu.
  • Menerapkan delta inventory tanpa idempotensi. Operasi increment adalah sumber bug dobel yang klasik.
  • Tidak membedakan event snapshot dan delta. Keduanya punya risiko berbeda terhadap event lama.
  • Tidak menyimpan raw payload. Saat ada sengketa data, Anda kehilangan bukti dan konteks.
  • Mengembalikan 500 untuk duplicate yang sebenarnya aman. Akibatnya pengirim terus me-retry.
  • Verifikasi signature dari body yang sudah berubah. JSON parser atau middleware bisa mengubah representasi body.

Rekomendasi Implementasi Minimum yang Realistis

Jika Anda membangun integrasi save game dari nol dan ingin mulai dengan desain yang aman tanpa terlalu kompleks, baseline berikut sudah sangat membantu:

  1. Endpoint webhook menerima raw body dan memverifikasi HMAC signature.
  2. Payload wajib punya event_id, event_type, player_id, save_slot, sequence, dan idempotency_key.
  3. Simpan event mentah ke tabel inbound_webhook_events dengan unique constraint.
  4. Balas 202 Accepted setelah event tersimpan dan job di-queue.
  5. Worker memproses event dalam transaksi, memegang lock pada state pemain, dan hanya menerapkan event dengan sequence lebih baru.
  6. Duplicate delivery dikembalikan sebagai sukses, bukan error.
  7. Retry worker memakai exponential backoff dengan jitter.
  8. Event gagal permanen dipindahkan ke dead-letter queue atau status investigasi manual.

Pola ini tidak bergantung pada framework tertentu. Ia bekerja baik di stack yang memakai queue SQL, Redis, broker pesan, atau worker internal selama prinsip dasarnya dijaga.

Penutup

Desain webhook untuk save game bukan sekadar menerima JSON lalu menulis ke database. Sistem yang benar harus dirancang dengan asumsi bahwa request bisa diulang, urutannya bisa kacau, dan kegagalan parsial pasti terjadi. Karena itu, kontrak payload, verifikasi signature, idempotency key, kontrol sequence, retry dengan backoff, dan event log bukan fitur tambahan—semuanya adalah fondasi.

Dalam game kecil seperti Letters to Tomorrow, bug inventory dobel atau progress mundur terasa “kecil” di awal, tetapi dampaknya langsung ke kepercayaan pemain. Jika Anda menerapkan pola di atas sejak awal, sistem save game akan jauh lebih mudah diaudit, di-debug, dan dikembangkan tanpa takut integrasi webhook merusak state pemain.