Skip to main content

Game

Overview

The game module is the core of Upmatches. It lets an authenticated user organise a match for a specific activity at a venue, set pricing and slot limits, bound the allowed skill-level range, and invite other users to join. Games can be PUBLIC (discoverable in listings) or PRIVATE (reachable only via share link).

Every response envelope conforms to the API overview.

Data Model

Game

FieldTypeDescription
idUUIDAuto-generated primary key
activityActivitySummary{ id, name } — immutable after creation
organizerOrganizerSummary{ id, name, contactMethods }name is Anonymous if the organiser hasn't set one; contactMethods is the organiser's whatsapp / telegram / messenger list
venueVenueSummary{ id, name, address, latitude, longitude }
skillLevel{ from, to }Allowed range — both must belong to the game's activity and to.sortOrder >= from.sortOrder
currencystringISO-4217 3-letter code (e.g. SGD)
pricedecimalPer-player cost, >= 0.00, up to 8 integer digits + 2 decimals
slotsintegerTotal seats, 1..100 — cannot be reduced below the current active participant count
numberOfPlayerJoinedlongActive participant count (derived)
startTime / endTimeInstantendTime must be after startTime
visibilityGameVisibilityPUBLIC or PRIVATE — immutable after creation
gameTypeGameTypeSINGLE or DOUBLE
statusGameStatusSCHEDULED, STARTED, EXPIRED, or CANCELLED
remarkstring?Free-form note from the organiser, up to 500 chars
shareLinkShareLinkResponse?Generated on create for PUBLIC games (see Share Link)
viewerJoinabilityViewerJoinabilityResponse?Whether the calling user can join, plus warnings — populated only for authenticated viewers on detail / participant endpoints
viewerIsOrganizerboolean?true when the calling user is the organiser; null for anonymous viewers
createdAt / updatedAtInstantAudit timestamps

Enums

EnumValues
GameStatusSCHEDULED, STARTED, EXPIRED, CANCELLED
GameVisibilityPUBLIC, PRIVATE
GameTypeSINGLE, DOUBLE
ParticipantStatusACTIVE, LEFT, EXPELLED
JoinabilityReasonALREADY_JOINED, GAME_FULL, GAME_NOT_JOINABLE, SKILL_OUT_OF_RANGE, OVERLAPS_WITH_EXISTING_GAME, …
JoinabilityWarningSKILL_MISMATCH, PARTICIPANT_PENALTY_RECORD, …

Scheduler

Background jobs transition games from SCHEDULED to STARTED once startTime has passed, and from STARTED to EXPIRED once endTime has passed. CANCELLED is reserved for explicit organiser action (future endpoint).

API Contract

Game endpoints are split between three controllers:

  • /api/v1/games — read endpoints (GET) are public and rate-limited via PublicReadRateLimitFilter; write endpoints require a JWT.
  • /api/v1/game-bookmarks — per-user bookmarks; require a JWT.
  • /api/v1/admin/games — destructive admin operations; require the ADMIN role.

See the API overview for the envelope and error shapes. My-games listings live under /api/v1/me/games.

POST /api/v1/games

Creates a game. The organiser is the authenticated user. A share link is generated in the same transaction for PUBLIC games.

cURL

curl -X POST http://localhost:8080/api/v1/games \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"activityId": "550e8400-e29b-41d4-a716-446655440000",
"venueId": 12,
"skillLevelFromId": 3,
"skillLevelToId": 5,
"currency": "SGD",
"price": "8.50",
"slots": 8,
"startTime": "2026-05-01T19:00:00Z",
"endTime": "2026-05-01T21:00:00Z",
"visibility": "PUBLIC",
"gameType": "DOUBLE",
"joinAsOrganizer": true,
"remark": "Bring your own shuttles"
}'

Request

FieldTypeValidation
activityIdUUIDRequired
venueIdlongRequired
skillLevelFromIdlongRequired, must belong to activityId
skillLevelToIdlongRequired, must belong to activityId, sortOrder >= from
currencystringRequired, matches ^[A-Z]{3}$
pricedecimalRequired, >= 0.00, 8 int digits + 2 decimals
slotsintegerRequired, 1..100
startTime / endTimeInstantRequired, endTime after startTime
visibilityGameVisibilityRequired
gameTypeGameTypeRequired, SINGLE or DOUBLE
joinAsOrganizerboolean?If true, organiser is added as an ACTIVE participant
remarkstring?Optional note, max 500 chars

Response 201 Created

{
"success": true,
"data": {
"id": "7a9a3b1a-0000-0000-0000-000000000001",
"activity": { "id": "550e8400-...", "name": "Badminton" },
"organizer": {
"id": "d290f1ee-...",
"name": "Jane Doe",
"contactMethods": [
{ "name": "whatsapp", "value": "+6591234567" }
]
},
"venue": {
"id": 12,
"name": "Clementi Sports Hall",
"address": "518 Clementi Ave 1",
"latitude": 1.3150,
"longitude": 103.7651
},
"skillLevel": {
"from": { "id": 3, "name": "High Beginner", "sortOrder": 3 },
"to": { "id": 5, "name": "Middle Intermediate", "sortOrder": 5 }
},
"currency": "SGD",
"price": 8.50,
"slots": 8,
"numberOfPlayerJoined": 1,
"startTime": "2026-05-01T19:00:00Z",
"endTime": "2026-05-01T21:00:00Z",
"visibility": "PUBLIC",
"gameType": "DOUBLE",
"status": "SCHEDULED",
"remark": "Bring your own shuttles",
"shareLink": {
"id": "...",
"code": "aB3xY9kQ",
"resourceType": "GAME",
"resourceId": "7a9a3b1a-...",
"shareUrl": "http://localhost:8080/api/v1/share-links/aB3xY9kQ",
"webUrl": "...",
"mobileUrl": "...",
"clickCount": 0,
"createdAt": "..."
},
"viewerJoinability": null,
"viewerIsOrganizer": true,
"createdAt": "2026-04-15T12:00:00Z",
"updatedAt": "2026-04-15T12:00:00Z"
},
"message": "Game created successfully.",
"timestamp": "2026-04-15T12:00:00Z",
"path": "/api/v1/games"
}

The creator must have completed onboarding; otherwise the request is rejected with 409 Conflict (OnboardingRequiredException). Overlapping games (same organiser, overlapping [startTime, endTime)) are rejected with 409 Conflict.

GET /api/v1/games

Public (no JWT required, rate-limited). Cursor-based listing with optional filters. Sorted by startTime then id ascending. PRIVATE games are excluded unless the caller is authenticated and is the organiser or an active participant.

cURL

curl -X GET "http://localhost:8080/api/v1/games?size=20&fromDate=2026-05-01&toDate=2026-05-31&venueIds=12&latitude=1.3150&longitude=103.7651&radiusKm=3"

Query parameters

ParameterTypeValidation
cursorstringOpaque Base64 cursor returned by a previous response. Omit for the first page.
sizeinteger1..1000, default 20
venueIdslong[]Optional, max 20 entries
skillLevelIdslong[]Optional, max 20 entries
fromDateLocalDateOptional. Interpreted in SGT
toDateLocalDateOptional, must be >= fromDate
fromTimeLocalTimeOptional
toTimeLocalTimeOptional
latitudedecimalOptional, -90.0..90.0
longitudedecimalOptional, -180.0..180.0
radiusKmintegerOptional, 1..5

If any of latitude, longitude, or radiusKm is supplied, all three must be supplied together.

Response 200 OKdata is a CursorPagedResponse<GameResponse>:

{
"success": true,
"data": {
"content": [ { "id": "7a9a3b1a-...", "...": "..." } ],
"nextCursor": "eyJzdGFydFRpbWUiOiIyMDI2LTA1LTAxVDE5OjAwOjAwWiIsImlkIjoiN2E5YTNiMWEtLi4uIn0"
},
"message": "Games retrieved successfully.",
"timestamp": "2026-04-15T12:00:00Z",
"path": "/api/v1/games"
}

nextCursor is null when there are no further pages. A malformed cursor returns 400 Bad Request. Anonymous callers are rate-limited at 60 req/min per IP; authenticated callers at 240 req/min per user.

GET /api/v1/games/filter-options

Public. Returns the distinct venues, skill levels, start-times, and game types present in the filtered result set. Intended to back progressive filter UIs. Samples up to 500 games from the filtered range.

cURL

curl -X GET "http://localhost:8080/api/v1/games/filter-options?fromDate=2026-05-01&toDate=2026-05-31"

Query parameters

ParameterTypeValidation
fromDateLocalDateRequired
toDateLocalDateRequired, >= fromDate, range <= 90 days
venueIdslong[]Optional, max 20
skillLevelIdslong[]Optional, max 20
fromTime / toTimeLocalTimeOptional
gameTypeGameTypeOptional, SINGLE or DOUBLE
latitude / longitude / radiusKmOptional, all three must be supplied together; same bounds as above

Response 200 OK

{
"success": true,
"data": {
"venues": [ { "id": 12, "name": "Clementi Sports Hall", "...": "..." } ],
"skillLevels": [ { "id": 3, "name": "High Beginner", "sortOrder": 3 } ],
"availableStartTimes": ["19:00", "20:00", "21:00"],
"gameTypes": ["SINGLE", "DOUBLE"]
},
"message": "Game filter options retrieved successfully.",
"timestamp": "2026-04-15T12:00:00Z",
"path": "/api/v1/games/filter-options"
}

availableStartTimes are formatted HH:mm in SGT.

GET /api/v1/games/date-availability

Public. Returns whether at least one non-deleted game exists on the given date. The date is interpreted in Asia/Singapore; the service queries games whose startTime falls within [date 00:00 SGT, next-day 00:00 SGT).

cURL

curl -X GET "http://localhost:8080/api/v1/games/date-availability?date=2026-04-28"

Query parameters

ParameterTypeValidation
dateLocalDateRequired, ISO format yyyy-MM-dd

Response 200 OK

{
"success": true,
"data": {
"date": "2026-04-28",
"hasGame": true
},
"message": "Game date availability retrieved successfully.",
"timestamp": "2026-04-28T12:00:00Z",
"path": "/api/v1/games/date-availability"
}

GET /api/v1/games/{id}

Public. Returns a single game. PRIVATE games are returned only to the organiser, active participants, or callers presenting a valid share access.

cURL

curl -X GET http://localhost:8080/api/v1/games/7a9a3b1a-...

PUT /api/v1/games/{id}

Updates a game. Only the organiser may update. The following fields are immutable and ignored if present: activityId and visibility. slots may be raised but not reduced below the current ACTIVE participant count. Everything else follows the same validation as creation.

cURL

curl -X PUT http://localhost:8080/api/v1/games/7a9a3b1a-... \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"price": "10.00",
"gameType": "SINGLE",
"startTime": "2026-05-01T19:30:00Z",
"endTime": "2026-05-01T21:30:00Z"
}'

Request — all fields optional; omitted fields are left unchanged.

Response 200 OK — full GameResponse. Reducing slots below the current active participant count returns 409 Conflict.

DELETE /api/v1/games/{id}

Soft-deletes the game (sets deletedAt). Organiser only. Soft-deleted games are excluded from listings. Active participants and the organiser are notified.

Response 204 No Content

POST /api/v1/games/{id}/participants

Joins the authenticated user to the game. Rejects if the game is full, past, cancelled, or if the caller is already ACTIVE. For PRIVATE games the caller must have resolved the share link first.

Response 201 Created

{
"success": true,
"data": {
"id": "e1...",
"gameId": "7a9a3b1a-...",
"userId": "d290f1ee-...",
"userName": "Jane Doe",
"status": "ACTIVE",
"joinedAt": "2026-04-15T12:05:00Z",
"leftAt": null
},
"message": "Joined game successfully.",
"timestamp": "2026-04-15T12:05:00Z",
"path": "/api/v1/games/7a9a3b1a-.../participants"
}

DELETE /api/v1/games/{id}/participants/me

Leaves the game. The participant row is not removed — its status becomes LEFT and leftAt is stamped. The organiser cannot leave their own game.

Response 204 No Content

GET /api/v1/games/{id}/participants

Lists every participant on the game (status ACTIVE, LEFT, and EXPELLED). Available to any authenticated caller able to read the game.

Response 200 OK

{
"success": true,
"data": [
{
"id": "e1...",
"gameId": "7a9a3b1a-...",
"userId": "d290f1ee-...",
"userName": "Jane Doe",
"status": "ACTIVE",
"skillLevel": { "id": 4, "name": "Low Intermediate", "sortOrder": 4 },
"joinedAt": "2026-04-15T12:05:00Z",
"leftAt": null
}
],
"message": "Game participants retrieved successfully.",
"timestamp": "2026-04-15T12:05:00Z",
"path": "/api/v1/games/7a9a3b1a-.../participants"
}

DELETE /api/v1/games/{gameId}/participants/{participantId}

Organiser-only. Expels a participant from the game with an optional reason. The participant row is preserved with status = EXPELLED, leftAt = now(), and expelReason is stored. The expelled user receives a PLAYER_REMOVED_FROM_GAME notification.

Request (optional body)

{ "reason": "Repeated no-shows." }
FieldTypeValidation
reasonstring?Optional, max 500 chars

Response 204 No Content

DELETE /api/v1/admin/games/{id}

Admin only. Hard-deletes the game (row removed). Cascades to participants, bookmarks, and share accesses. Use only for moderation — prefer DELETE /api/v1/games/{id} (soft-delete) for normal organiser flows.

Response 204 No Content

Game Bookmarks

Bookmarks are per-user pointers to games the user wants to revisit. They do not imply participation.

POST /api/v1/game-bookmarks

Request

{ "gameId": "7a9a3b1a-..." }

Response 201 CreatedGameBookmarkResponse including the embedded GameResponse. Re-bookmarking the same game returns 409 Conflict (AlreadyBookmarkedException).

GET /api/v1/game-bookmarks

Cursor-paginated listing of the caller's bookmarks.

ParameterTypeValidation
cursorstringOpaque cursor from a previous response
sizeinteger1..1000, default 20

DELETE /api/v1/game-bookmarks/{gameId}

Removes the caller's bookmark for the given game. Returns 204 No Content whether or not the bookmark existed.

Error Handling

ScenarioHTTP StatusNotes
Missing onboarding, caller tries to create409Complete profile first (OnboardingRequiredException)
Overlapping game for same organiser409Reschedule
Skill-level range invalid (wrong activity or reversed order)400See validation messages
Reducing slots below current participants409GameSlotsBelowParticipantsException
Join attempt on full game409GameFullException
Join attempt on past / cancelled game409GameNotJoinableException
Organiser tries to leave own game409GameNotJoinableException
Non-organiser tries to update / delete / expel404Returned as 404, not 403, to avoid enumeration
PRIVATE game accessed without share context404Present share link first
Non-admin calls DELETE /api/v1/admin/games/{id}403AccessDeniedException
Malformed cursor, incomplete location filter, toDate < fromDate, or filter-options date range > 90 days400Validation error
Public read rate limit exceeded429Retry after the Retry-After window