Everyone likes rewards, and that's why we've just added Daily Rewards to Cosmo Crafter.
I've dedicated a few hours of my weekend to achieve this, and now I share it with you.
Thoughts before actions...
The first few hours of this challenge were set trying to decide exactly how the feature would work.
I had a few variations in mind:
- The user could just receive a random reward every new day he logs in;
- The user could choose from three options, on a daily basis;
- The user could see the pre-establish rewards for a 7-day cycle/strike, if he logs in daily;
- Both 1 and 2 could become better rewards with a bigger strike;
After hours of thought, the most engaging option would be a mix of them all. We allow the user to select from three options, each generated randomly based on the current strike of the player. The bigger the strike, the better, and bigger the rewards. We have one of the rewards being a "Mystery Chest", containing a random, unpredictable reward.
Hands to work...
My first objective was to track the daily strike of the user.
Database migration
To track the daily strike, I decided to create a new table to store the user sessions:
php artisan make:model -m UserSession
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('userSessions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('userId');
$table->foreign('userId')->references('id')->on('users');
$table->date('loginAt');
$table->integer('loginStrike')->default(0);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('userSessions');
}
};
User model changes
private function loginStrike(string $when, int $default = 0) : int {
return UserSession::where('userId', $this->id)
->whereDate('loginAt', $when)
->max('loginStrike') ?? $default;
}
public function lastLoginStrike() {
return $this->loginStrike(now()->subDay()->format('Y-m-d'), 0);
}
public function currentLoginStrike() {
return $this->loginStrike(now()->format('Y-m-d'), 1);
}
These methods will help me establish the current, and the last login strike of the user, so that now, on the controller, whenever the user logs in (or wherever you've decided to place this logic), we can increase his strike.
UserSession::create([
'userId' => $user->id,
'loginAt' => now()->format('Y-m-d'),
'loginStrike' => ($user->lastLoginStrike() + 1)
]);
Now, that every time the user logs in the loginStrike increases, and breaks after 1 day without login, I'm ready to go to the next step which is loading the rewards to the user.
OK, first things first:
Another migration...
php artisan make:model -m UserReward
This time, we need a migration to track when rewards are collected by the user.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('userRewards', function (Blueprint $table) {
$table->unsignedBigInteger('userId');
$table->foreign('userId')->references('id')->on('users');
$table->date('rewardedAt');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('userRewards');
}
};
...alternatively you could've opted out by adding a flag to the userSessions table which would determine wether the reward has been claimed or not. I decided to keep them into separate tables because for Cosmo Crafter's particular use case, I've added additional columns (omitted on this tutorial) that I need for my business case.
Once we have our database migration ready, we can move into the routes, controllers and their logics...
Ready, steady: action!
I've created two main routes:
Route::get('/rewards', [ RewardsController::class, 'getRewards' ]);
// and
Route::post('/claim', [ RewardsController::class, 'claimReward' ]);
The objective of these two routes is to allow the frontend to get the available rewards for the day, and to claim them!
So, when retrieving the rewards we will have to start by checking if the user hasn't claimed their rewards for the day. We can do so by adding the following method to the User model:
public function hasClaimedRewardToday() {
return UserReward::where('userId', $this->id)
->whereDate('rewardedAt', now()->format('Y-m-d'))
->first();
}
...afterwards we will need to get the users current strike and get the possible rewards for the strike, for the effect we have created a class named "DailyRewards" that help us determine the options for the daily strike.
Once we invoke the method strikeRewards from the DailyRewards object, we get an array with 3 options that the user will be able to select from on the frontend.
With the rewardId the user will be able to pick his reward by sending a request to the /claim endpoint.
Here's the controller that wraps this up:
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\UserReward;
use App\Models\UserSession;
use Illuminate\Http\Request;
class RewardsController extends Controller {
public function getRewards(Request $request) {
$user = auth()->user();
if($user->hasClaimedRewardsToday()) { return response([
'Success' => false,
'Claimed' => true
]); }
$strike = $user->currentLoginStrike();
$dailyRewards = (new DailyRewards())->strikeRewards($strike);
return response([
'Success' => true,
'DailyRewards' => $dailyRewards,
'CurrentStrike' => $strike,
]);
}
public function claimReward(Request $request) {
$user = auth()->user();
if($user->hasClaimedRewardsToday()) { return response([
'Success' => false,
'Claimed' => true
]); }
$strike = $account->currentLoginStrike();
$dailyRewards = (new DailyRewards())->strikeRewards($strike);
return !isset($dailyRewards[$request->rewardId])) ? response([
'Success' => false,
'Claimed' => false
]) : response([
'OK' => true,
'Claim' => (new DailyRewards())->claim($user, $dailyRewards[$request->rewardId]),
]);
}
}