DEV Community

Cover image for AWS Use Cases | Enhanced Streak System for Game Portal with Leaderboards & Rewards
Minoltan Issack
Minoltan Issack

Posted on • Originally published at issackpaul95.Medium

AWS Use Cases | Enhanced Streak System for Game Portal with Leaderboards & Rewards

Introduction to Streaks

A streak is a consecutive count of days (or actions) a user performs a specific activity without breaking the chain. Streaks are commonly used in:

  • Habit-tracking apps (e.g., Duolingo, Headspace)
  • Gaming (daily login rewards, consecutive wins)
  • Fitness apps (workout consistency)
  • E-learning platforms (daily learning goals)

How AWS Helps Implement Streaks

AWS provides serverless and scalable solutions to track streaks efficiently:

  • AWS Lambda → Runs streak logic (increment, reset, reward checks) DynamoDB → Stores user streak data (last activity, current streak count)
  • API Gateway → Exposes APIs for frontend (web/mobile apps)
  • Amazon Cognito (Optional) → Handles user authentication
  • AWS CDK → Easy Deployment

Use Cases for Streaks & Implementation Steps

1. Daily Login Streaks (Gaming/Fitness Apps)

Goal: Reward users for logging in daily.

Implementation Steps:

1. Set Up DynamoDB Table

  • Table: UserStreak
  • Partition Key: userId (String)
  • Sort Key: streakType
  • Attributes: currentStreak, lastLogin, longestStreak

2. Create streakTrack Lambda Function

  • Checks if the user logged in today → skip
  • If logged in yesterday → increment streak
  • If missed a day → reset streak
import { UpdateItemCommand, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./client";

const TABLE_NAME = process.env.STREAK_TABLE_NAME;
const MAX_FREEZE_DAYS = 2;

export const handler = async (event) => {
  try {
    const { userId } = JSON.parse(event.body);
    if (!userId) {
      return { statusCode: 400, body: JSON.stringify({ error: "userId is required" }) };
    }

    const today = new Date().toISOString().split("T")[0];
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    const yesterdayStr = yesterday.toISOString().split("T")[0];

    // ✅ Get current streak and freeze days
    const { currentStreak, lastLogin, freezeDaysRemaining } = await getUserData(userId);

    // ✅ If already logged in today
    if (lastLogin === today) {
      return success({ message: "Already logged in today", currentStreak, freezeDaysRemaining });
    }

    let newStreak = 1;
    let newFreeze = freezeDaysRemaining;

    // ✅ Case 1: Consecutive login (yesterday)
    if (lastLogin === yesterdayStr) {
      newStreak = currentStreak + 1;
    } 
    // ✅ Case 2: Missed days but has freeze days → use one
    else if (freezeDaysRemaining > 0) {
      newStreak = currentStreak; // keep streak intact
      newFreeze = freezeDaysRemaining - 1; // use one freeze day
    }

    // ✅ Update DB
    await updateUserData(userId, today, newStreak, newFreeze);

    return success({
      message: freezeDaysRemaining > 0 && lastLogin !== yesterdayStr ? 
        "Missed day covered by a freeze day" : "Streak updated",
      currentStreak: newStreak,
      freezeDaysRemaining: newFreeze
    });

  } catch (err) {
    console.error("Error:", err);
    return { statusCode: 500, body: JSON.stringify({ error: err.message }) };
  }
};

// 🔹 Get user streak & freeze data
async function getUserData(userId) {
  const { Item } = await ddbClient.send(new GetItemCommand({
    TableName: TABLE_NAME,
    Key: marshall({ userId, streakType: "daily" }), // using same PK as freeze
  }));

  if (!Item) return { currentStreak: 0, lastLogin: null, freezeDaysRemaining: 0 };

  const data = unmarshall(Item);
  return {
    currentStreak: data.currentStreak || 0,
    lastLogin: data.lastLogin || null,
    freezeDaysRemaining: data.freezeDaysRemaining || 0
  };
}

// 🔹 Update streak and freeze count
async function updateUserData(userId, today, newStreak, newFreeze) {
  await ddbClient.send(new UpdateItemCommand({
    TableName: TABLE_NAME,
    Key: marshall({ userId, streakType: "daily" }),
    UpdateExpression: "SET currentStreak = :cs, lastLogin = :dt, freezeDaysRemaining = :fd",
    ExpressionAttributeValues: marshall({
      ":cs": newStreak,
      ":dt": today,
      ":fd": newFreeze
    })
  }));
}

// 🔹 Helper success response
function success(body) {
  return {
    statusCode: 200,
    headers: { "Access-Control-Allow-Origin": "*" },
    body: JSON.stringify(body)
  };
}
Enter fullscreen mode Exit fullscreen mode

3.Create streakFreeze Lambda Function

import { GetItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./client.js";

const STREAK_TABLE_NAME = process.env.STREAK_TABLE_NAME;

export const handler = async (event) => {
    try {
        const { userId } = await validateAndParseInput(event.body);

        const { freezeDaysRemaining, itemExists } = await getCurrentFreezeDays(userId);

        if (freezeDaysRemaining >= 2) {
            return formatErrorResponse(400, "Maximum freeze days (2) already reached");
        }

        const updatedFreeze = await updateFreezeDays(userId, freezeDaysRemaining, itemExists);

        return {
            statusCode: 200,
            headers: { "Access-Control-Allow-Origin": "*" },
            body: JSON.stringify({
                status: "success",
                freezeDaysRemaining: updatedFreeze
            })
        };

    } catch (error) {
        console.error("handler: ", error);
        return formatErrorResponse(400, error.message);
    }
};

async function validateAndParseInput(body) {
    const payload = JSON.parse(body);
    const { userId } = payload;

    if (!userId) {
        throw new Error("Missing required field: userId");
    }

    return { userId };
}

async function getCurrentFreezeDays(userId) {
    const { Item } = await ddbClient.send(new GetItemCommand({
        TableName: STREAK_TABLE_NAME,
        Key: marshall({ userId, streakType: "daily" }),
        ProjectionExpression: "freezeDaysRemaining"
    }));

    return {
        freezeDaysRemaining: Item ? unmarshall(Item).freezeDaysRemaining || 0 : 0,
        itemExists: !!Item
    };
}

async function updateFreezeDays(userId, currentFreezeDays, itemExists) {
    const updateParams = {
        TableName: STREAK_TABLE_NAME,
        Key: marshall({ userId, streakType: "daily" }),
        UpdateExpression: "SET freezeDaysRemaining = :newVal",
        ExpressionAttributeValues: marshall({ ":newVal": currentFreezeDays + 1 }),
        ReturnValues: "ALL_NEW"
    };

    if (!itemExists) {
        // For new records, set additional default values
        updateParams.UpdateExpression = "SET freezeDaysRemaining = :newVal, currentStreak = :zero, longestStreak = :zero, lastActivity = :empty";
        updateParams.ExpressionAttributeValues = marshall({
            ":newVal": 1,
            ":zero": 0,
            ":empty": ""
        });
    }

    const { Attributes } = await ddbClient.send(new UpdateItemCommand(updateParams));
    return unmarshall(Attributes).freezeDaysRemaining;
}

function formatErrorResponse(statusCode, message) {
    return {
        statusCode,
        headers: { "Access-Control-Allow-Origin": "*" },
        body: message
    };
}
Enter fullscreen mode Exit fullscreen mode

4. Set Up API Gateway

  • POST /streak/track → Triggers Lambda
  • POST /streak/freeze

5. Frontend Integration

  • Call API when user logs in
  • Display streak count

Example Explanation

Initial Conditions

  • currentStreak = 3
  • freezeDaysRemaining = 1
  • lastLogin = 2025-07-28

✅ Case 1: User logs in on 2025–07–29 (yesterday was last login)

  • Lambda receives event: { "userId": "1134" }
  • It checks lastLogin === yesterday (2025-07-28) → ✅ yes.
  • No freeze day is used.
  • currentStreak = 4, freezeDaysRemaining = 1
  • Response:
{
  "message": "Streak updated",
  "currentStreak": 4,
  "freezeDaysRemaining": 1
}
Enter fullscreen mode Exit fullscreen mode

✅ Case 2: User skips 2025–07–29, logs in on 2025–07–30

  • Missed one day (2025–07–29)
  1. Lambda checks: lastLogin = 2025-07-28, today = 2025-07-30
  2. lastLogin !== yesterday, so normally streak would reset.
  3. But freezeDaysRemaining > 0 → ✅ use one freeze.
  4. currentStreak stays 3, freezeDaysRemaining = 0
  5. Response:
{
  "message": "Missed day covered by a freeze day",
  "currentStreak": 3,
  "freezeDaysRemaining": 0
}
Enter fullscreen mode Exit fullscreen mode

✅ Case 3: User skips 2025–07–31, logs in on 2025–08–01

  • Missed two consecutive days and has no freeze left
  1. Lambda checks: lastLogin = 2025-07-28, today = 2025-08-01
  2. lastLogin !== yesterday, and freezeDaysRemaining = 0
  3. No freeze day available → streak resets to 1
  4. currentStreak = 1, freezeDaysRemaining = 0
  5. Response:
{
  "message": "Streak updated",
  "currentStreak": 1,
  "freezeDaysRemaining": 0
}
Enter fullscreen mode Exit fullscreen mode

✅ Case 4: User later earns a freeze day (via freeze API)

  • User calls /streak/freeze with { "userId": "1134", "action": "add" }
  • Freeze Lambda increments freezeDaysRemaining but caps it at 2.
{
  "status": "success",
  "freezeDaysRemaining": 1
}
Enter fullscreen mode Exit fullscreen mode

✅ Case 5: User tries to manually use a freeze

  • Calls /streak/freeze with { "userId": "1134", "action": "use" }
  • Lambda checks: freezeDaysRemaining > 0 → ✅ yes, decreases by 1.
  • If already 0, returns error:
{ "error": "No freeze days remaining" }
Enter fullscreen mode Exit fullscreen mode

🔥 How This Works Together

1. Streak Lambda

  • Auto-consumes freeze only when needed (user missed a day).
  • Never lets streak reset unnecessarily if freeze is available.

2. Freeze Lambda

  • Adds freeze days when rewarded.
  • Allows manual usage (optional) if needed.

2. Consecutive Wins Streak (Gaming Leaderboards)

Goal: Track players’ winning streaks and reward top performers.
Implementation Steps:

1. DynamoDB Table

  • UserStreak
  • PK: userId
  • Sort Key: streakType
  • Attributes: currentWinStreak, maxWinStreak, lastWinDate

2. Lambda Function

  • After a game ends, check if the player won
  • Increment streak if last game was a win
  • Reset if lost
import { GetItemCommand, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./client.js";

const TABLE_NAME = process.env.STREAK_TABLE_NAME;

export const handler = async (event) => {
  try {
    const { userId, won } = JSON.parse(event.body);

    if (!userId || won === undefined) {
      return formatResponse(400, { error: "userId and won (true/false) are required" });
    }

    const today = new Date().toISOString().split("T")[0];
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    const yesterdayStr = yesterday.toISOString().split("T")[0];

    // Get current game streak data
    const { currentWinStreak, maxWinStreak, lastWinDate } = await getGameStreak(userId);

    let newWinStreak = currentWinStreak;
    let newMaxWinStreak = maxWinStreak;

    if (won) {
      // If last game was yesterday, continue streak, else reset to 1
      newWinStreak = lastWinDate === yesterdayStr ? currentWinStreak + 1 : 1;

      // Update max streak
      if (newWinStreak > maxWinStreak) {
        newMaxWinStreak = newWinStreak;
      }

      // Update DynamoDB
      await updateGameStreak(userId, today, newWinStreak, newMaxWinStreak);
    } else {
      // Player lost → reset current streak
      newWinStreak = 0;
      await updateGameStreak(userId, today, newWinStreak, maxWinStreak);
    }

    return formatResponse(200, {
      message: won ? "Game won streak updated" : "Game lost, streak reset",
      currentWinStreak: newWinStreak,
      maxWinStreak: newMaxWinStreak
    });

  } catch (err) {
    console.error("Error updating game streak:", err);
    return formatResponse(500, { error: err.message });
  }
};

// 🔹 Get current streak from DynamoDB
async function getGameStreak(userId) {
  const { Item } = await ddbClient.send(new GetItemCommand({
    TableName: TABLE_NAME,
    Key: marshall({ userId, streakType: "game" }),
    ProjectionExpression: "currentWinStreak, maxWinStreak, lastWinDate"
  }));

  if (!Item) {
    return { currentWinStreak: 0, maxWinStreak: 0, lastWinDate: null };
  }

  const data = unmarshall(Item);
  return {
    currentWinStreak: data.currentWinStreak || 0,
    maxWinStreak: data.maxWinStreak || 0,
    lastWinDate: data.lastWinDate || null
  };
}

// 🔹 Update streak in DynamoDB
async function updateGameStreak(userId, today, currentWinStreak, maxWinStreak) {
  await ddbClient.send(new UpdateItemCommand({
    TableName: TABLE_NAME,
    Key: marshall({ userId, streakType: "game" }),
    UpdateExpression: "SET currentWinStreak = :cws, maxWinStreak = :mws, lastWinDate = :ld",
    ExpressionAttributeValues: marshall({
      ":cws": currentWinStreak,
      ":mws": maxWinStreak,
      ":ld": today
    }),
    ReturnValues: "UPDATED_NEW"
  }));
}

// 🔹 Helper response formatter
function formatResponse(statusCode, body) {
  return {
    statusCode,
    headers: { "Access-Control-Allow-Origin": "*" },
    body: JSON.stringify(body)
  };
}
Enter fullscreen mode Exit fullscreen mode

✅ Example Flow
🟢 Case 1: User wins consecutive games

  • lastWinDate: 2025–07–30
  • today: 2025–07–31
  • Result: currentWinStreak = 3, maxWinStreak = 3

🔴 Case 2: User loses

  • won: false
  • Result: currentWinStreak = 0, maxWinStreak stays as it was.

Conclusion

Streaks are a powerful engagement tool, and AWS makes implementation easy:
✅ Serverless & Scalable (Lambda + DynamoDB)
✅ Real-Time Updates (API Gateway)
✅ Reward Integration (Lambda + DynamoDB)
✅ Cost-Effective (Pay-per-use pricing)

Next Steps:

  1. Start with a basic daily login streak
  2. Expand to game win streaks and habit tracking
  3. Add rewards & leaderboards for higher engagement

Advance on Streaks

1. Milestone Offers (Risk/Reward)

When users hit milestones (e.g., 7 days), give them a choice:

  • Option A: Continue safely (streak grows normally)
  • Option B: Gamble (“Break your streak now for 3x rewards!”)

2. Smart Streak Logic

  • Tracks timezone-aware daily activity
  • Handles edge cases (midnight checks, server delays)

3. Leaderboard Logic

  • Add reward for higher in the leaderboard

For CDK Implementation — My Reposiotry


To stay informed on the latest technical insights and tutorials, connect with me on Medium, LinkedIn and Dev.to. For professional inquiries or technical discussions, please contact me via email. I welcome the opportunity to engage with fellow professionals and address any questions you may have.

Top comments (0)