[BE] ArcadeController — Coin Flip PvP matchmaking endpoints

Open
#463 0 comments 0 reactions 0 assignees View on GitHub

@pedroafmonteiro is already working on this.

Since Oct 28, 2026.

  • #481 by @pedroafmonteiro — open

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
38/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Active
Tech stack
laravel, php, postgresql
Domain
api, backend

Research direction

Start with routes/web.php, where the arcade route group goes, then create app/Http/Controllers/ArcadeController.php with the coin-flip methods. The models and ArcadeWagerService it depends on come from #460 and #462, so check those exist before coding against them. Read the Enrollment model and the points rules in the issue: changes go through DB::transaction() with lockForUpdate(), and never through the pivot tables. Done means the acceptance criteria pass: 422 on self-join or wager mismatch, the 5-minute cancel refund, and 403 on status for non-participants.

Written by the indexing model from the issue text.

Description

backend gamification P0

🎮 Project Context: SINF Gamification & Arcade System

What is SINF and why are we building this?

Semana de Informática (SINF) is an annual tech conference organized by NIAEFEUP for university students. During the conference, attendees earn event tokens ("SINFrões" / points) by attending talks, workshops, and completing sponsor quests (scanning QR codes at company booths). Historically, these tokens were only spent at the physical conference store (/shop).

To massively increase student engagement throughout the days of the event, we are building a Gamification / Arcade & Prize Draw platform:

  • Arcade (/arcade): Participants can wager their earned tokens on 5 mini-games (PvP Coin Flip, High/Low Dice, Bug Sweeper, Mastermind, and Binary Blitz).
  • Prize Draws (/raffle): Participants can sink tokens into daily and final raffle pools to win hardware and sponsor prizes.
  • Admin Management: Event organizers can configure multiplier payouts (payout_config), toggle games on/off, create raffle pools, and trigger weighted-random winner drawings directly inside the admin dashboard.

🛡️ Core Economy & Security Rules (MUST READ FOR ALL DEVS)
  1. Points Storage: Points are stored as an unsigned integer in enrollments.points (one row per participant per edition).
  2. PostgreSQL Trigger Caution: The database has existing triggers that modify points when rows are inserted/deleted from enrollment_slot and enrollment_product.
    • NEVER insert into or delete from those pivot tables for arcade games or raffles!
    • Directly mutating enrollments.points via PHP ($enrollment->decrement('points', $wager)) does not fire those triggers and is the only safe way to deduct/credit points.
  3. No Negative Balance Constraint in DB: There is no CHECK (points >= 0) in PostgreSQL. All validation against negative balances must occur in PHP.
  4. Mandatory Concurrency Protection: Our attendees are Computer Science students who understand network inspection and race conditions. All token adjustments must run inside DB::transaction() with $enrollment = Enrollment::lockForUpdate()->find(...).
  5. Server Authority: Game outcomes, RNG (random_int()), secret state (bombs, codes), and time limits must be verified strictly server-side.

🗓️ 3-Week Timeline & Where Your Task Fits
  • Week 1 (Foundation - Current Phase): Database tables (#458, #459), Eloquent models (#460, #461), ArcadeWagerService security core (#462), Coin Flip matchmaking backend (#463), Arcade lobby page (#464), and shared UI components (#465).
  • Week 2 (Game Building): 5 developer pairs concurrently build the 5 games (#466–#470) on top of this foundation.
  • Week 3 (Raffles, Admin & QA): Raffle purchase flow (#471–#473), Admin CRUD (#474–#477), Global Navbar state (#478), and Security Test Suite (#479).

🎯 Why Issue #463 is Critical

You are implementing the backend for our first game: Coin Flip PvP Matchmaking.

  • Unlike solo games, Coin Flip pairs two real participants against each other.
  • Player A stakes tokens and creates a pending session; Player B browses the lobby, matches the stake, and triggers the server-side flip (random_int(0, 1)).
  • You are also implementing the 5-minute timeout refund and polling endpoints.
    This controller establishes the reference architecture for stateful multi-player sessions in this codebase.

Context & Objective

Coin Flip is the only PvP game. The architecture is a simple matchmaking queue:

  1. Player A calls POST /arcade/coin-flip/start with a wager → tokens are immediately deducted, a session with outcome='pending' is created.
  2. Player A polls GET /arcade/coin-flip/{session}/status every 2 seconds waiting for an opponent.
  3. Player B browses GET /arcade/coin-flip/lobby and sees Player A's open session.
  4. Player B calls POST /arcade/coin-flip/{session}/join with a matching wager → tokens deducted, coin flipped server-side (random_int(0, 1)), winner credited.
  5. Player A's next poll returns the result.
  6. If no one joins after 5 minutes, Player A can call POST /arcade/coin-flip/{session}/cancel for a full refund.

This issue implements only the backend endpoints. The FE component is #466.

Files to Create / Modify

  • Create: app/Http/Controllers/ArcadeController.php (coin-flip methods only)
  • Modify: routes/web.php — add the arcade route group

Technical Requirements

Routes (add to routes/web.php)
Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified', 'throttle:arcade'])
    ->prefix('/arcade')
    ->name('arcade.')
    ->group(function () {
        Route::get('/', [ArcadeController::class, 'lobby'])->name('lobby');

        Route::prefix('coin-flip')->name('coin-flip.')->group(function () {
            Route::get('/lobby', [ArcadeController::class, 'pendingCoinFlips'])->name('lobby');
            Route::post('/start', [ArcadeController::class, 'startCoinFlip'])->name('start');
            Route::post('/{session}/join', [ArcadeController::class, 'joinCoinFlip'])->name('join');
            Route::get('/{session}/status', [ArcadeController::class, 'statusCoinFlip'])->name('status');
            Route::post('/{session}/cancel', [ArcadeController::class, 'cancelCoinFlip'])->name('cancel');
        });
    });
startCoinFlip(Request $request)
$request->validate(['wager' => 'required|integer|min:1']);
$game = ArcadeGame::where('slug', 'coin-flip')
    ->where('edition_id', $edition->id)
    ->firstOrFail();
Gate::authorize('play', $game);

$enrollment = $user->usertype->enrollments()->where('edition_id', $edition->id)->firstOrFail();

$session = DB::transaction(function () use ($enrollment, $request, $game) {
    $wager = $request->integer('wager');
    abort_if($wager < $game->min_wager || $wager > $game->max_wager, 422, 'Aposta fora dos limites');
    app(ArcadeWagerService::class)->deductWager($enrollment->id, $wager);
    return ArcadeSession::create([
        'enrollment_id' => $enrollment->id,
        'arcade_game_id' => $game->id,
        'wager' => $wager,
        'outcome' => 'pending',
    ]);
});

return response()->json(['session_id' => $session->id]);
joinCoinFlip(Request $request, ArcadeSession $session)
  • Validate: session outcome === 'pending', challenger_enrollment_id is null, the joiner's enrollment_id !== session->enrollment_id (can't join own game), joiner's wager equals session->wager.
  • Inside DB::transaction():
    • ArcadeWagerService::deductWager($challengerEnrollment->id, $session->wager)
    • $result = random_int(0, 1) — 0 = creator wins, 1 = challenger wins
    • $payout = (int) floor($session->wager * 2 * (1 - $game->payout_config['house_edge_pct'] / 100))
    • Credit payout to winner's enrollment
    • Update session: outcome = 'win'|'loss', payout, challenger_enrollment_id, resolved_at = now()
  • Return JSON with {winner: 'creator'|'challenger', payout}
statusCoinFlip(ArcadeSession $session)
  • Gate: session must belong to $user's enrollment OR be the challenger.
  • Return JSON: {outcome, payout, challenger_enrollment_id, resolved_at}.
cancelCoinFlip(ArcadeSession $session)
  • Gate: session must belong to current user's enrollment. Must be outcome === 'pending'. Must be older than 5 minutes (started_at < now()->subMinutes(5)).
  • Refund: $enrollment->increment('points', $session->wager).
  • Update session: outcome = 'expired', resolved_at = now().
pendingCoinFlips()
  • Return paginated ArcadeSession::pending()->where('arcade_game_id', $coinFlipGame->id)->latest()->get() — only sessions the current user did NOT create.

Acceptance Criteria

  • Player cannot join their own session (HTTP 422)
  • Player cannot join a session that is already matched or resolved
  • Wager must match the session's wager to join (HTTP 422 otherwise)
  • Both players' tokens are deducted before the coin is flipped
  • The server uses random_int(), never rand() or client-provided outcome
  • Payout correctly applies house_edge_pct from payout_config
  • Cancel only works after 5 minutes of pending and only for the session creator
  • Refund on cancel is exact (no house cut)
  • Status endpoint returns 403 if neither the creator nor the challenger requests it

Dependencies

  • #460 (ArcadeGame, ArcadeSession models)
  • #462 (ArcadeWagerService, ArcadePolicy, rate limiter)
Dominant language
Vue
Stars
12
Forks
1
PR merge metrics
No merged PRs in 30d

Getting set up

  • Ships a Dockerfile or Docker Compose file
  • No pull request template
  • No contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from semanadeinformatica/website

All issues in semanadeinformatica/website

Similar issues

More Backend & API Design issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.