Pada game puzzle multiplayer atau puzzle asinkron, masalah utama backend bukan sekadar menyimpan langkah pemain, tetapi menjaga state tetap konsisten ketika aksi datang dari banyak sumber: pemain manusia, AI, retry dari client, worker queue, dan kadang webhook internal. Jika state tidak disinkronkan dengan benar, Anda akan melihat gejala seperti langkah terduplikasi, puzzle lompat ke posisi yang salah, giliran tertukar, atau cache menampilkan papan lama.
Solusi yang umum dipakai di produksi adalah memisahkan ingest aksi dari aplikasi state memakai queue dan cache, lalu menambahkan idempotensi, locking, dan observabilitas. Pendekatan ini tidak membuat sistem menjadi "selalu sinkron" secara instan, tetapi membuatnya deterministik, dapat diulang, dan aman terhadap kegagalan.
Mengapa sinkronisasi state puzzle multiplayer sulit
Puzzle multiplayer yang melibatkan manusia dan AI punya karakteristik yang berbeda dari CRUD biasa:
- Aksi bisa datang bersamaan. Pemain manusia menekan tombol pada saat AI juga selesai menghitung langkah.
- Urutan kedatangan tidak selalu sama dengan urutan kejadian. Jaringan, retry, dan queue dapat membuat event datang terlambat atau terproses tidak berurutan.
- Retry dapat mengulang efek. Request timeout di client belum tentu berarti server gagal. Jika client mengirim ulang aksi tanpa idempotensi, state bisa berubah dua kali.
- Cache mudah basi. Worker sudah mengubah state di database, tetapi API masih membaca snapshot lama dari Redis atau memory cache.
- Lock bisa kedaluwarsa. Jika worker lambat atau crash, lock bisa lepas terlalu cepat atau malah tertinggal terlalu lama.
Karena itu, backend perlu dirancang dengan asumsi bahwa duplicate, delay, reordering, dan partial failure akan terjadi.
Arsitektur backend yang praktis
Arsitektur yang aman biasanya memisahkan komponen berikut:
- API layer: menerima aksi dari client atau AI orchestrator.
- Command store / action log: menyimpan aksi mentah beserta metadata seperti action_id, game_id, actor_id, expected_version, dan timestamp.
- Queue: mengantrikan pekerjaan penerapan aksi ke state game.
- Worker: memproses aksi secara serial per game atau dengan kontrol konkurensi yang ketat.
- Database utama: sumber kebenaran untuk state final atau event log.
- Cache: menyimpan snapshot state, turn info, atau hasil query yang sering dibaca.
- Observability stack: log terstruktur, metrik queue, tracing, dan alert.
Pola sederhananya seperti ini:
Client / AI -> API -> validate command -> persist action -> enqueue job
|
v
Worker
|
lock game_id -> load current state
|
apply action deterministically
|
commit DB -> update/invalidate cache
|
publish state update / websocketKunci dari arsitektur ini adalah: aksi tidak langsung mengubah state dari request thread jika ada potensi konflik tinggi. API menerima, memvalidasi, dan mencatat aksi. Worker yang bertugas menerapkan aksi ke state secara terkendali.
Model data yang mendukung consistency
State snapshot dan action log
Untuk puzzle multiplayer, model yang umum dan praktis adalah menggabungkan:
- Snapshot state per game: representasi papan, giliran, skor, status selesai, dan versi state.
- Action log: daftar aksi yang pernah diterima, termasuk hasil prosesnya.
Contoh field yang berguna pada action log:
- action_id: ID unik global untuk idempotensi
- game_id: game yang dituju
- actor_type: human atau ai
- actor_id: identitas pemain atau agent
- action_type: move, rotate, hint, submit, dll
- payload: isi aksi
- expected_version: versi state yang diyakini client saat mengirim aksi
- status: pending, applied, rejected, duplicate
- created_at / processed_at
expected_version penting untuk mendeteksi konflik optimistis. Misalnya client mengira state saat ini versi 18, tetapi worker melihat game sudah di versi 19. Pada titik itu, Anda bisa menolak aksi, melakukan rekonsiliasi, atau menjalankan validasi ulang tergantung aturan game.
Mengapa versi state lebih berguna daripada timestamp saja
Timestamp tidak cukup untuk menentukan urutan yang sah karena jam bisa berbeda dan event bisa tertunda. Monotonic version per game lebih mudah dipakai untuk memastikan bahwa aksi diterapkan di atas basis state yang tepat.
Alur event yang aman dari request sampai state terbarui
- API menerima aksi dengan action_id dari client, atau membuatkannya jika sumber tidak menyediakan.
- Validasi sintaks: payload benar, actor berhak bermain, game masih aktif.
- Simpan action log dengan unique constraint pada action_id.
- Enqueue job dengan kunci deduplikasi yang mengacu ke action_id atau kombinasi game_id + action sequence.
- Worker mengambil job, memperoleh lock untuk game_id, lalu memuat snapshot state terbaru.
- Validasi semantik: apakah aksi masih sah pada state terbaru.
- Terapkan perubahan state dalam transaksi database.
- Naikkan version state jika aksi berhasil diterapkan.
- Update atau invalidasi cache.
- Publikasikan update ke websocket, polling endpoint, atau event bus internal.
Urutan ini penting karena banyak bug muncul ketika cache diubah duluan, event disiarkan duluan, atau aksi diterapkan tanpa menyimpan jejak idempotensi.
Masalah umum dan cara menanganinya
1. Event dobel akibat retry client atau redelivery queue
Kasus paling umum: client mengirim aksi, koneksi timeout, lalu client mengulang request. Di sisi lain, queue juga bisa mengirim ulang job jika worker crash setelah memproses tetapi sebelum mengakui penyelesaian job.
Solusinya adalah idempotensi di dua lapisan:
- API idempotency: action_id unik disimpan dengan unique constraint.
- State transition idempotency: worker harus bisa mengenali bahwa action_id tersebut sudah pernah diterapkan.
Contoh pseudo-SQL:
INSERT INTO game_actions (action_id, game_id, actor_id, payload, status)
VALUES (:action_id, :game_id, :actor_id, :payload, 'pending')
ON CONFLICT (action_id) DO NOTHING;Lalu saat worker memproses:
BEGIN;
SELECT status FROM game_actions WHERE action_id = :action_id FOR UPDATE;
-- jika status = 'applied', keluar tanpa mengubah state
-- jika status = 'pending', lanjutkan
SELECT version, board_state FROM games WHERE id = :game_id FOR UPDATE;
-- validasi dan apply
UPDATE games
SET board_state = :new_state,
version = version + 1
WHERE id = :game_id;
UPDATE game_actions
SET status = 'applied', processed_at = NOW()
WHERE action_id = :action_id;
COMMIT;Jika job diulang, status action sudah applied, sehingga worker tidak mengubah state lagi.
2. Event out-of-order
Misalnya AI mengirim langkah berdasarkan state versi 20, tetapi langkah manusia versi 21 sudah lebih dulu diterapkan. Jika aksi AI tetap dipaksa jalan, puzzle bisa masuk state tidak valid.
Beberapa strategi yang bisa dipilih:
- Tolak jika expected_version tidak cocok. Cocok untuk game turn-based yang ketat.
- Recompute di worker. Cocok jika aksi bisa divalidasi terhadap state terbaru dan masih bermakna.
- Serialisasi per game di queue/worker. Ini mengurangi out-of-order pada tahap proses, walau tidak menghapus masalah versi lama dari client.
Pada game puzzle, pilihan terbaik biasanya adalah serialisasi per game + pengecekan versi. Dengan begitu, urutan proses terjaga, dan aksi lama bisa ditolak secara eksplisit.
3. Retry yang merusak state
Retry tanpa batas sering dianggap aman padahal dapat memperparah masalah jika handler tidak idempotent. Contoh buruk: worker gagal mengirim notifikasi lalu seluruh transaksi permainan dijalankan ulang, padahal state sudah berubah.
Praktiknya:
- Bedakan side effect utama (ubah state game) dan side effect turunan (push websocket, analytics, notifikasi).
- Simpan hasil perubahan state di transaksi utama.
- Jalankan side effect turunan setelah commit, idealnya melalui outbox atau event terpisah.
Dengan pola ini, retry notifikasi tidak akan mengulang perubahan state puzzle.
4. Cache basi
Cache berguna untuk leaderboard mini, snapshot papan, atau endpoint polling yang sering dipanggil. Namun cache menjadi sumber bug jika tidak ada aturan invalidasi yang jelas.
Pola yang relatif aman:
- DB sebagai source of truth.
- Cache-aside untuk read-heavy endpoint.
- Invalidasi setelah commit, bukan sebelum.
- Sertakan version pada cache payload agar client atau service lain bisa mendeteksi snapshot lama.
Contoh key cache:
game:{game_id}:snapshot
{
"version": 42,
"turn": "human",
"board": [...],
"updated_at": "..."
}Jika worker selesai commit versi 43, ia bisa:
- menghapus key snapshot agar request berikutnya memuat dari DB lalu mengisi ulang cache, atau
- menulis snapshot baru langsung ke cache jika representasi state sudah tersedia penuh di worker.
Trade-off: write-through ke cache memberi latensi baca lebih baik, tetapi menambah kompleksitas. Invalidasi lebih sederhana, tetapi ada jendela kecil ketika pembaca harus kembali ke DB.
5. Lock timeout
Distributed lock, misalnya di Redis, sering dipakai untuk memastikan hanya satu worker memproses game tertentu dalam satu waktu. Masalahnya: jika timeout lock terlalu pendek, job panjang bisa kehilangan lock di tengah jalan. Jika terlalu panjang, game bisa tampak macet saat worker mati.
Aturan praktis:
- Gunakan lock hanya untuk bagian kritis yang sesingkat mungkin.
- Pastikan ada owner token sehingga hanya pemilik lock yang boleh melepasnya.
- Pertimbangkan lock renewal untuk job yang sah memang lama, tetapi hati-hati terhadap kondisi worker hang.
- Jangan mengandalkan lock saja; tetap gunakan transaksi DB dan pengecekan versi.
Lock terdistribusi membantu mengurangi konkurensi, tetapi bukan pengganti idempotensi dan validasi state. Banyak sistem rusak justru karena terlalu percaya bahwa lock selalu sempurna.
6. Race condition saat manusia dan AI mengirim aksi bersamaan
Ini skenario inti pada puzzle yang melibatkan manusia dan AI. Keduanya dapat menghasilkan aksi valid menurut state yang sama, tetapi hanya satu yang boleh menang.
Pendekatan yang umum:
- Optimistic concurrency jika konflik jarang dan aksi ringan.
- Distributed/per-game lock jika konflik sering, aturan giliran ketat, atau satu game bisa menerima burst event.
Contoh keputusan sederhana:
- Jika game strict turn-based, lock per game biasanya lebih mudah dipahami.
- Jika game relatif longgar dan aksi bisa direkonsiliasi, optimistic concurrency lebih efisien.
Optimistic concurrency vs distributed lock
Optimistic concurrency
Pola ini memakai version check saat update:
UPDATE games
SET board_state = :new_state,
version = version + 1
WHERE id = :game_id AND version = :expected_version;Jika jumlah baris yang berubah adalah 0, berarti ada konflik.
Kelebihan:
- Implementasi sederhana di database relasional.
- Tidak butuh service lock tambahan.
- Baik untuk konflik yang jarang.
Kekurangan:
- Konflik tinggi menyebabkan banyak retry atau reject.
- Jika aksi mahal dihitung sebelum update, komputasi bisa terbuang.
- Perlu desain API yang jelas untuk menangani conflict response.
Distributed lock
Pola ini mencegah dua worker masuk ke bagian kritis yang sama secara bersamaan, misalnya lock dengan key lock:game:{game_id}.
Kelebihan:
- Mudah menjaga serialisasi per game.
- Baik untuk burst aksi pada game yang sama.
- Mengurangi konflik write di database.
Kekurangan:
- Menambah komponen dan failure mode baru.
- Risiko lock timeout, orphaned lock, atau lock renewal bermasalah.
- Throughput bisa turun jika lock terlalu kasar.
Rekomendasi praktis: gunakan kombinasi. Lock per game untuk membatasi konkurensi, dan version check di database sebagai pagar terakhir jika lock gagal atau ada kondisi tak terduga.
Dedup job di queue
Banyak anomali berawal dari job yang sama masuk beberapa kali. Ini bisa terjadi karena retry publisher, crash setelah enqueue, atau pemanggil API yang mengirim aksi identik dengan ID berbeda.
Strategi dedup yang masuk akal:
- Key dedup berbasis action_id untuk mencegah job identik diproses dua kali.
- TTL dedup yang cukup untuk menutup jendela retry, tetapi tidak terlalu lama hingga menghalangi aksi sah berikutnya.
- Status job yang eksplisit di storage jika queue tidak menjamin exactly-once.
Jangan menganggap queue memberi exactly-once delivery. Di banyak sistem nyata, yang lebih realistis adalah at-least-once delivery, sehingga consumer harus idempotent.
Strategi invalidasi cache yang aman
Kapan invalidasi lebih baik daripada overwrite
Jika format cache adalah hasil query gabungan dari beberapa tabel atau ada banyak jalur update, invalidasi sering lebih aman daripada mencoba menjaga semua cache selalu up-to-date. Overwrite cocok jika worker memiliki seluruh state final yang baru dan hanya ada satu jalur penulisan dominan.
Gunakan versi pada key atau payload
Anda bisa menyimpan:
game:{id}:snapshotdengan field version, ataugame:{id}:v:{version}untuk immutable snapshot sementara.
Pola versi pada key memudahkan pembaca menghindari data lama, tetapi perlu kebijakan pembersihan agar memori cache tidak bocor.
Hindari write ke cache sebelum transaksi commit
Kesalahan klasik: state baru ditulis ke cache, lalu transaksi DB gagal. Akibatnya cache memuat state yang tidak pernah benar-benar tersimpan. Jika memungkinkan, update cache hanya setelah commit berhasil.
Contoh implementasi alur worker
function processAction(actionId) {
action = actionRepo.find(actionId)
if (!action) return
if (action.status === 'applied' || action.status === 'rejected') {
return
}
lock = lockService.acquire(`game:${action.gameId}`, 5000)
if (!lock) {
throw RetryLater
}
try {
db.transaction(() => {
action = actionRepo.findForUpdate(actionId)
if (action.status === 'applied' || action.status === 'rejected') {
return
}
game = gameRepo.findForUpdate(action.gameId)
if (action.expectedVersion != null && game.version !== action.expectedVersion) {
actionRepo.markRejected(actionId, 'version_conflict')
return
}
result = puzzleEngine.apply(game.state, action.payload, action.actorId)
if (!result.valid) {
actionRepo.markRejected(actionId, result.reason)
return
}
gameRepo.updateState(action.gameId, result.newState, game.version + 1)
actionRepo.markApplied(actionId)
outboxRepo.add('game_state_updated', {
gameId: action.gameId,
version: game.version + 1
})
})
cache.del(`game:${action.gameId}:snapshot`)
} finally {
lock.release()
}
}Di contoh ini ada beberapa prinsip penting:
- Worker aman terhadap redelivery karena status action dicek ulang.
- Lock dipakai untuk membatasi konkurensi antar-worker.
- DB transaction tetap menjadi pagar utama untuk state.
- Outbox memisahkan perubahan state dari event lanjutan.
- Cache di-invalidasi setelah transaksi sukses.
Observabilitas: apa yang wajib dipantau
Tanpa observabilitas, bug sinkronisasi akan terlihat seperti "kadang state loncat" tanpa jejak jelas. Minimal pantau hal-hal berikut:
Metrik
- Queue depth per tipe job
- Age of oldest job
- Retry count dan dead-letter count
- Conflict rate pada version check
- Duplicate action rate
- Lock acquisition latency dan lock timeout count
- Cache hit/miss untuk snapshot game
- Selisih versi antara DB dan payload yang dipublish
Log terstruktur
Setiap proses aksi sebaiknya punya korelasi minimal:
- action_id
- game_id
- actor_id
- actor_type
- expected_version
- applied_version
- job_id
- lock_wait_ms
- outcome: applied / rejected / duplicate / conflict
Tracing
Jika sistem Anda punya banyak komponen, trace dari API -> enqueue -> worker -> DB -> cache -> publish sangat membantu untuk menemukan di mana urutan mulai rusak atau latensi melonjak.
Runbook singkat saat anomali muncul
Gejala: langkah pemain terduplikasi
- Cek apakah action_id benar-benar unik dari client.
- Cek unique constraint pada action log.
- Lihat apakah worker menandai action sebagai applied sebelum crash.
- Pastikan consumer queue idempotent terhadap redelivery.
Gejala: urutan langkah salah
- Periksa apakah worker memproses satu game secara paralel.
- Cek expected_version dan conflict rate.
- Lihat apakah AI mengirim aksi berdasarkan snapshot lama.
- Audit apakah ada jalur write lain yang melewati worker utama.
Gejala: client melihat papan lama
- Bandingkan versi state di DB dan cache.
- Pastikan invalidasi cache terjadi setelah commit.
- Periksa apakah ada local memory cache di service lain yang terlupakan.
- Cek apakah websocket mengirim payload lama dari worker retry.
Gejala: game terasa macet
- Lihat queue backlog dan age of oldest job.
- Periksa lock timeout atau orphaned lock.
- Cek dead-letter queue untuk action yang gagal terus.
- Audit transaction yang terlalu panjang atau query lambat pada load state.
Trade-off desain yang perlu dipilih sejak awal
Tidak ada satu desain yang sempurna untuk semua game puzzle. Beberapa keputusan yang perlu dibuat:
- Snapshot-only vs event-sourced: snapshot lebih sederhana; event log penuh lebih audit-friendly tetapi lebih kompleks.
- Optimistic-only vs lock + optimistic: optimistic cukup untuk konflik rendah; kombinasi lebih aman untuk game aktif dengan AI.
- Invalidate cache vs write-through cache: invalidasi lebih simpel; write-through memberi baca cepat namun lebih rumit.
- Strict reject vs auto-reconcile: reject lebih deterministik; reconcile bisa lebih ramah pengguna tetapi lebih sulit dibuktikan benar.
Untuk kebanyakan backend produksi puzzle multiplayer, desain yang seimbang adalah:
- action log dengan action_id unik,
- worker queue idempotent,
- serialisasi per game dengan lock seperlunya,
- version check di database,
- cache-aside dengan invalidasi setelah commit,
- outbox untuk event lanjutan.
Penutup
Sinkronkan state puzzle multiplayer dengan queue dan cache bukan berarti memindahkan semua masalah ke Redis atau worker. Intinya adalah membuat alur perubahan state yang deterministik, idempotent, dan terukur. Queue membantu meratakan beban dan mengendalikan urutan proses. Cache membantu performa baca. Tetapi consistency tetap ditentukan oleh disiplin pada action log, versioning, locking, transaksi, dan invalidasi cache.
Jika Anda membangun game puzzle yang dimainkan manusia dan AI secara bersamaan, anggap duplicate event, out-of-order delivery, retry, dan stale cache sebagai kondisi normal. Dengan desain ini, sistem Anda tidak harus sempurna setiap saat, tetapi dapat pulih dengan benar dan menjaga state game tetap masuk akal di bawah beban nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!