طراحی مکانیزم امتیازدهی بازی با Redis (Scoring)

قبل از هر چیز یک اعتراف صادقانه:
این سناریو لزوماً از دل یک بازی واقعی نیامده. بیشتر یک مدل ذهنی است برای این‌که بفهمیم Redis چطور فکر می‌کند و چطور می‌شود از آن برای ساخت مکانیزم‌های بازی استفاده کرد.

فرض کنیم یک بازی داریم به اسم Super Redis Brothers؛
یک بازی پلتفرمر آنلاین که به یک سرور مرکزی وصل است. شخصیت اصلی بازی، Redisman، باید سکه جمع کند، با سکه‌ها power-up بگیرد و زنده بماند.

تمرکز ما در این مقاله فقط روی امتیازدهی (جمع‌کردن سکه) است.

ساده‌ترین حالت: شمارنده با String

اگر فقط بخواهیم تعداد سکه‌های یک بازیکن را نگه داریم، ساده‌ترین راه استفاده از یک counter است.

مثلاً:

INCR pID123

یا اگر چند سکه با هم جمع شود:

INCRBY pID123 10

کم‌کردن امتیاز هم با:

DECRBY pID123 5

این روش:

  • سریع است (O(1))

  • ساده است

  • ولی به‌شدت محدود

مشکل کجاست؟
به محض این‌که بخواهی:

  • لیدربورد بسازی

  • بازیکن‌ها را با هم مقایسه کنی

  • رتبه بدهی
    دیگر string جواب نمی‌دهد.

چرا Sorted Set انتخاب بهتری است؟

اینجاست که Sorted Set وارد بازی می‌شود.

ایده خیلی ساده است:

  • یک کلید داریم برای همه‌ی امتیازها

  • member = شناسه‌ی بازیکن

  • score = تعداد سکه‌ها

با این مدل:

  • هم امتیاز هر بازیکن را داری

  • هم می‌توانی رتبه‌بندی بگیری

  • هم لیدربورد بسازی

مدل‌سازی جمع‌کردن سکه با Sorted Set

فرض کنیم همه‌ی امتیازها زیر این کلید ذخیره می‌شوند:

scores

وقتی بازیکن سکه جمع می‌کند:

ZINCRBY scores 10 pID1234

چند نکته‌ی مهم اینجا وجود دارد:

  • اگر بازیکن قبلاً وجود نداشته باشد، Redis از صفر شروع می‌کند

  • score صفر یک مقدار معتبر است

  • score منفی هم مجاز است (که بعداً به دردسرش می‌خوریم!)

پیچیدگی این عملیات:

  • O(log n)

  • برای بازی و آپدیت‌های پرتعداد کاملاً قابل قبول

نمایش سکه‌ها در HUD بازی

برای نمایش تعداد سکه‌ها روی صفحه‌ی بازی، فقط کافی است امتیاز فعلی را بگیریم:

ZSCORE scores pID1234

نکته‌ی جالب:

  • ZINCRBY خودش امتیاز جدید را برمی‌گرداند

  • یعنی همان پاسخ Redis می‌تواند مستقیم وارد HUD شود

  • بدون کوئری اضافه

خرج‌کردن سکه‌ها (Power-Up)

فرض کنیم یک power-up داریم که:

  • ۱۰ سکه می‌گیرد

  • دشمن‌ها را فریز می‌کند

از نظر Redis:

ZINCRBY scores -10 pID1234

کاملاً منطقی.

یا اگر بخواهیم سکه‌ها را صفر کنیم (مثل Sonic وقتی ضربه می‌خورد):

ZADD scores 0 pID1234

اینجا شاید ZADD عجیب به نظر برسد،
ولی در واقع یک upsert است:

  • اگر عضو باشد → امتیازش آپدیت می‌شود

  • اگر نباشد → با امتیاز صفر اضافه می‌شود

مشکل واقعی: امتیاز منفی!

اینجا به یک ساده‌سازی خطرناک می‌رسیم.

اگر Redisman صفر سکه داشته باشد و power-up بخورد:

ZINCRBY scores -10 pID1234

نتیجه:

-10

که احتمالاً در منطق بازی غلط است.

پس قبل از کم‌کردن امتیاز، باید چک کنیم:

آیا بازیکن به‌اندازه‌ی کافی سکه دارد یا نه؟

حل مسئله با Lua Script (اتمیک و تمیز)

منطق به زبان ساده:

  1. امتیاز فعلی را بگیر

  2. اگر کافی بود، کم کن

  3. اگر نه، هیچ کاری نکن

به‌صورت شبه‌کد:

if currentScore >= neededCoins:
  decrement

در Redis، بهترین جا برای این منطق یک Lua Script است؛
چون:

  • اتمیک اجرا می‌شود

  • بین ZSCORE و ZINCRBY هیچ عملیات دیگری نمی‌پرد

اسکریپت نمونه:

EVAL "
if tonumber(redis.call('zscore',KEYS[1],ARGV[2])) >= (ARGV[1]*-1)
then
  return tonumber(redis.call('zincrby',KEYS[1],ARGV[1],ARGV[2]))
end
" 1 scores -10 pID1234

این اسکریپت:

  • اول امتیاز را می‌گیرد

  • چک می‌کند به زیر صفر نرود

  • فقط در صورت مجاز بودن، امتیاز را کم می‌کند

در محیط production:

  • معمولاً از SCRIPT LOAD و EVALSHA استفاده می‌شود

  • ولی منطق دقیقاً همین است

ساخت لیدربورد با Sorted Set

اگر تا این‌جا همراه بوده باشی، حالا یک نکته باید کاملاً روشن باشد:
Sorted Set تقریباً برای لیدربورد ساخته شده است.

ما همین حالا هم امتیاز هر بازیکن را به‌عنوان score داخل یک Sorted Set نگه می‌داریم.
پس بخش بزرگی از منطق لیدربورد از قبل آماده است.

نمایش نفرات برتر (Top-N)

در بیشتر بازی‌ها، لیدربورد از بیشترین امتیاز به کمترین نمایش داده می‌شود.
برای همین از ZREVRANGE استفاده می‌کنیم.

ZREVRANGE scores 0 9 WITHSCORES

این دستور:

  • ۱۰ بازیکن اول را برمی‌گرداند

  • بالاترین امتیاز در ابتدای لیست است

  • امتیازها هم همراه اسم بازیکن‌ها برمی‌گردد

نکته‌ی مهم:

  • Redis تساوی امتیازها را هم درست مدیریت می‌کند

  • ممکن است ۱۰ نفر اول، همگی امتیاز یکسان داشته باشند

  • ترتیب همچنان پایدار و قابل پیش‌بینی است

لیدربورد «اطراف من» (نه فقط Top 10)

نمایش ۱۰ نفر اول برای همه مفید نیست.

اگر کاربر تازه‌وارد باشد و رتبه‌اش مثلاً ۴۸٬۳۲۱ باشد،
دیدن Top 10 هیچ انگیزه‌ای ایجاد نمی‌کند.

آنچه کاربر واقعاً می‌خواهد ببیند:

  • چند نفر بالاتر از خودش

  • چند نفر پایین‌تر از خودش

گرفتن رتبه‌ی کاربر

اول باید بفهمیم بازیکن چندم است:

ZREVRANK scores pID1234

این دستور:

  • رتبه‌ی بازیکن را از بالا برمی‌گرداند

  • دقیقاً همان چیزی که برای لیدربورد لازم داریم

نمایش بازه‌ای اطراف کاربر

حالا که رتبه را داریم، می‌توانیم مثلاً:

  • ۵ نفر بالاتر

  • خود کاربر

  • ۵ نفر پایین‌تر

را با یک ZREVRANGE بگیریم.

ایده ساده است:

start = rank - 5
end   = rank + 5

و بعد:

ZREVRANGE scores start end WITHSCORES

این مدل لیدربورد:

  • بسیار شخصی‌تر است

  • انگیزه‌ی رقابت ایجاد می‌کند

  • در بازی‌های واقعی بسیار مؤثرتر از Top-N است

اما یک مشکل جدی در مقیاس بالا

تا این‌جا همه‌چیز عالی به نظر می‌رسد،
اما این مدل یک نقطه‌ی ضعف معماری دارد.

در تمام مثال‌ها، ما فقط با یک کلید کار می‌کنیم:

scores

در مقیاس کوچک مشکلی نیست.
اما در یک بازی واقعی با ترافیک بالا چه اتفاقی می‌افتد؟

مشکل Hot Key

در Redis (به‌خصوص در حالت cluster):

  • هر کلید روی یک shard مشخص قرار می‌گیرد

  • تمام عملیات روی آن کلید، روی همان نود انجام می‌شود

نتیجه:

  • همه‌ی ZINCRBY

  • همه‌ی ZREVRANGE

  • همه‌ی ZREVRANK

روی یک ماشین می‌افتد.

حتی اگر:

  • ده‌ها shard

  • یا چندین نود داشته باشی

تا وقتی کلید یکی است،
گلوگاه همان یک shard خواهد بود.

به این حالت می‌گویند: Hot Key

در یک بازی با:

  • جمع‌کردن سکه‌ی زیاد

  • خرج‌کردن power-up

  • کاربران هم‌زمان زیاد

این محدودیت واقعاً خودش را نشان می‌دهد.

راه‌حل ساده؟ نه کاملاً

یک راه این است که:

  • امتیاز هر بازیکن را در یک key جداگانه با INCR نگه داریم

  • هر چند وقت یک‌بار با SCAN، یک Sorted Set بسازیم

اما این کار:

  • real-time بودن لیدربورد را از بین می‌برد

  • سیستم‌های جانبی و cron job می‌خواهد

  • پیچیدگی را بالا می‌برد

یعنی مشکل را حل نمی‌کند، فقط جابجا می‌کند.

یک راه‌حل مقیاس‌پذیرتر (ولی خاص)

در معماری‌های پیشرفته‌تر، می‌شود از Redis به‌صورت توزیع‌شده‌تر استفاده کرد.
مثلاً در Redis Enterprise Active-Active:

  • چند کلاستر هم‌رده داریم

  • نوشتن‌ها بین آن‌ها پخش می‌شود

  • داده‌ها بعداً با مدل CRDT همگام می‌شوند

در این حالت:

  • فشار نوشتن روی یک کلید تقسیم می‌شود

  • برای workloadهای bursty (افزایش ناگهانی ترافیک) مناسب‌تر است

  • real-time کامل نیست، ولی بسیار مقیاس‌پذیرتر است

این راه‌حل:

  • عمومی نیست

  • برای همه پروژه‌ها هم لازم نیست

  • ولی دانستنش برای طراحی سیستم‌های بزرگ حیاتی است

سخن پایانی

در بازی‌ها و سامانه‌های بلادرنگ، امتیازدهی و رتبه‌بندی فقط یک قابلیت ظاهری نیست؛ بخشی از هستهٔ طراحی سیستم است. Redis و به‌ویژه Sorted Set امکان پیاده‌سازی سریع و کارآمد مکانیزم‌هایی مثل امتیازدهی، لیدربورد و رتبهٔ شخصی کاربران را فراهم می‌کنند، اما در مقیاس بالا، تصمیم‌های معماری—مانند مدیریت hot key و الگوی توزیع داده—نقش تعیین‌کننده‌ای در پایداری سامانه دارند.

اگر در حال طراحی یا توسعهٔ بازی‌ها و سیستم‌های real-time با بار بالا هستید، تیم معماری و توسعهٔ نرم‌افزار شرکت راهکار نگار هوشمند (آرکان) می‌تواند از تحلیل مسئله تا طراحی معماری مقیاس‌پذیر و پیاده‌سازی عملیاتی، همراه شما باشد.

این مقاله با هدف انتقال تجربه و آگاهی‌بخشی فنی، توسط تیم توسعهٔ نرم‌افزار شرکت راهکار نگار هوشمند (آرکان) تهیه شده است.


#Redis
#SortedSet
#Leaderboard
#Game_Mechanics
#RealTime_Systems
#Scalable_Architecture
#Backend_Engineering
#System_Design
#High_Performance
#Distributed_Systems
#Software_Architecture
#شرکت_راهکار_نگار_هوشمند
#arcanco