[BE] ArcadeController — Coin Flip PvP matchmaking endpoints
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 38/100
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
🎮 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)
- Points Storage: Points are stored as an unsigned integer in
enrollments.points(one row per participant per edition). - PostgreSQL Trigger Caution: The database has existing triggers that modify points when rows are inserted/deleted from
enrollment_slotandenrollment_product.- NEVER insert into or delete from those pivot tables for arcade games or raffles!
- Directly mutating
enrollments.pointsvia PHP ($enrollment->decrement('points', $wager)) does not fire those triggers and is the only safe way to deduct/credit points.
- No Negative Balance Constraint in DB: There is no
CHECK (points >= 0)in PostgreSQL. All validation against negative balances must occur in PHP. - 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(...). - 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),
ArcadeWagerServicesecurity 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:
- Player A calls
POST /arcade/coin-flip/startwith a wager → tokens are immediately deducted, a session withoutcome='pending'is created. - Player A polls
GET /arcade/coin-flip/{session}/statusevery 2 seconds waiting for an opponent. - Player B browses
GET /arcade/coin-flip/lobbyand sees Player A's open session. - Player B calls
POST /arcade/coin-flip/{session}/joinwith a matching wager → tokens deducted, coin flipped server-side (random_int(0, 1)), winner credited. - Player A's next poll returns the result.
- If no one joins after 5 minutes, Player A can call
POST /arcade/coin-flip/{session}/cancelfor 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_idis null, the joiner'senrollment_id !== session->enrollment_id(can't join own game), joiner's wager equalssession->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
matchedorresolved - 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(), neverrand()or client-provided outcome - Payout correctly applies
house_edge_pctfrompayout_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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from semanadeinformatica/website
-
[BE] Eloquent Models — RaffleDraw & RaffleTicketPossibly taken @pedroafmonteiro claimed this 4 days ago. Openbackend gamification P0
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
semanadeinformatica/website#461 ·
-
[BE] Eloquent Models — ArcadeGame & ArcadeSessionPossibly taken @pedroafmonteiro claimed this 4 days ago. Openbackend gamification P0
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
semanadeinformatica/website#460 ·
-
[BE] DB Migration — raffle_draws & raffle_tickets tablesPossibly taken @pedroafmonteiro claimed this 4 days ago. Openbackend gamification P0
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
semanadeinformatica/website#459 ·
-
gamification P1
Difficulty 4/5 3-5 days Newbie friendliness 25/100
semanadeinformatica/website#479 ·
-
frontend gamification P1
Difficulty 3/5 1-2 days Newbie friendliness 62/100
semanadeinformatica/website#478 ·
All issues in semanadeinformatica/website
Similar issues
-
enhancement
Difficulty 1/5 Under an hour Newbie friendliness 78/100
-
Difficulty 2/5 Half a day Newbie friendliness 72/100
Maintainers usually reply within 3 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
grokability/snipe-it#19996 ·
Maintainers usually reply within 1 day
-
URLValidator ignores an explicit empty scheme allowlistPossibly taken @IlyaKhalafi claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
tortoise/tortoise-orm#2393 ·
Maintainers usually reply within 2 days
-
orchestrator: NFS-cache "cache path is not set" WARN logged on every build/launch instead of oncePossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 2 days