match-me
A Go and React matchmaking app with preference scoring, profile setup and realtime chat.
Overview
We built a gaming matchmaking application connecting profiles, mutual location preferences and weighted scoring with realtime chat and connection updates. The three-person project brought together a Go API, PostgreSQL data and a React client.
- context
- Coursework
- status
- Archived learning project
- team
- 3 people
- role
- Shared responsibilities
- started
- Dec 2024
- setting
- Full-stack coursework application with a Go API, PostgreSQL and React client.
my contribution
I contributed across the backend and frontend, particularly database integration, authentication, profiles, location filtering and recommendation scoring. Teammates contributed substantial chat, connection, presence and interface work.
- Go
- Gin
- PostgreSQL
- PostGIS
- JWT
- WebSockets
- +1
Key decisions
- Separate candidate eligibility from preference scores
- Shared games and languages act as hard gates, while matching attributes contribute weighted points with diminishing returns for repeated matches.
- Respect both users' search radii
- The location filter uses the smaller radius, making proximity a mutual condition rather than only the current user's preference.
- Connect profile setup to shared reference data
- The client uses API-provided options for games, preferences and locations, alongside the stored profile and authentication state.
Demonstrated results
- The recommendation pipeline is implemented end to end
- The source connects location filtering, exclusions, scoring and persistence to the client. Its ordering defect remains a separate, unresolved limitation.
- Realtime features were integrated across the team
- Chat, notifications, presence and connection updates are represented in the API and client, with the chat and connection work substantially contributed by teammates.
Known limitations
- The returned ranking loses its sort order
- A sorted slice becomes a Go map and the caller iterates that map, so paginated recommendations are not reliably returned in score order.
- Two WebSocket registrations miss authentication middleware
- The root-router registrations do not inherit the protected group's middleware. Running the archived version retains that access-control gap.
- Client weights lack defaults and range checks
- Missing keys contribute zero and extreme values can dominate the scoring. Parsing JSON is not sufficient validation of this configuration.
The ranking is two things, not one
Built as a three-person coursework project with shared responsibilities. Revisiting the code later revealed gaps the original documentation did not describe.
The API describes one operation: "rank candidates by weighted preference". The implementation is a filter followed by a score, and the filter is the part nobody can see from outside.
RankUsersByPreference walks ten attributes. Several halve the contribution of
additional matches, and shared games and languages also act as eligibility gates:
// 2point for matching games
preferenseScore := 2.0 * weights["games"]
for _, game := range user.Preference.Games {
for _, curGame := range currentUserGames {
if game == curGame {
score += preferenseScore
hasMatchingGames = true
preferenseScore /= 2
}
}
}
if !hasMatchingGames {
continue // <- not a zero score. A hard exclusion.
}
languageProficiency does the same thing. So a candidate with no shared games
or no shared language is discarded before any score exists. They cannot be
ranked last. They are never ranked at all. Those two attributes look like two of
ten weights and behave like two of two requirements.
| Attribute | Base | Diminishing | Gate |
|---|---|---|---|
| Games | 2 | ×½ per match | yes |
| Gaming style | 1 | — | — |
| Skill level | 1 | — | — |
| Time availability | 2 | ×½ per match | — |
| Platform | 2 | ×½ per match | — |
| Communication style | 2 | ×½ per match | — |
| Language proficiency | 2 | ×½ per match | yes |
| Favourite genres | 1 | ×½ per match | — |
| Game modes | 1 | ×½ per match | — |
| Competitive level | 1 | — | — |
Base points sum to 15. Note which attributes carry 2 and which carry 1: games, availability, platform, communication and language are worth double genres, modes and competitive level. That is a judgement about what makes a good duo, expressed as integer literals in a loop.
The weights come from the client, unvalidated
weightsStr := c.DefaultQuery("weights", "{}")
var weights map[string]float64
err = json.Unmarshal([]byte(weightsStr), &weights)
Then each attribute reads weights["games"], weights["skillLevel"] and so on.
There is no default and no range check, so a missing key contributes zero,
silently. A client that sends {} scores every candidate 0 on every attribute and
all candidates have equal scores before later map iteration loses ordering. A client that sends
{"games": 1000} decides the entire outcome on one boolean.
That said, the two gates still apply. A caller can weight games to zero and
still be gated by it. That is a strange shape: an attribute that contributes
nothing but can still exclude.
The sort that does not survive
This is the one worth reading twice.
sort.Slice(scoredUsers, func(i, j int) bool {
return scoredUsers[i].Score > scoredUsers[j].Score
})
rankedUsers := make(map[uuid.UUID]float64) // <- ordered slice becomes a map
for _, scoredUser := range scoredUsers {
rankedUsers[scoredUser.User.ID] = scoredUser.Score
}
return rankedUsers, nil
RankUsersByPreference sorts, then flattens the result into a map. The caller
rebuilds the ID list from that map:
recommendationIDs := make([]uuid.UUID, 0, len(rankedUsers))
for recID, score := range rankedUsers {
_ = database.CreateRecommendation(id, recID, score)
recommendationIDs = append(recommendationIDs, recID)
}
Go randomises map iteration order. The ranking is therefore correct for exactly one statement and gone by the next. The handler then paginates that random slice:
startIndex := offset
endIndex := offset + limit // limit defaults to 20
paginatedUsers := recommendationIDs[startIndex:endIndex]
So page 1 is not the top 20. It is 20 candidates in arbitrary order, and
consecutive pages can repeat a user while omitting someone the ranking considered
the best match in the country. The score is persisted, so the ordering could be
recovered with ORDER BY score DESC at read time. The data model supports the
behaviour the API fails to deliver.
The frontend asks for ?limit=20 and shows 10, using index 10 as its cursor.
Proximity is mutual, and computed by hand
Each user sets a search radius, and a candidate qualifies only if they fall inside both:
maxDistance := math.Min(currentUserLocation.Radius, otherUserLocation.Radius)
A user who wants a tight search effectively shrinks everyone else's. That is a reasonable interpretation of "how far am I willing to travel" meeting "how far are you willing to travel".
The distance itself is Haversine, in Go, with a hardcoded radius:
const earthRadius = 6371
...
distance := earthRadius * math.Atan2(y, x)
The migrations also create a spatial index:
CREATE INDEX IF NOT EXISTS idx_locations_coordinates
ON locations USING GIST (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326));
Nothing in the codebase calls ST_Distance or ST_DWithin. FilterUsersByLocation
calls GetAllUsers() first, then loops in Go over the entire result set. The GIST
index is pure write cost and wasted migration surface. The PostGIS dependency and
the index buy nothing, because the filter that needed them was never the one
implemented.
Two WebSockets with no auth
Gin middleware is attached to the router or group used to register the route.
This group applies authentication to routes registered through chatRoutes:
chatRoutes := router.Group("/chats")
chatRoutes.Use(middleware.AuthMiddleware())
{
chatRoutes.GET("", handlers.GetChatsHandler)
...
}
But two registrations appear after the group closes:
router.GET("/chats/:id/ws", handlers.WebSocketHandler)
router.GET("/ws/chats-update", handlers.ChatUpdateWSHandler)
These registrations use router, not chatRoutes, so they do not inherit that
group's middleware. The braces are ordinary Go scoping, not the security boundary.
The archived live-message endpoint and chat-list feed therefore retain the
authentication gap described in the issue record.
The fix is a one-line move, and the codebase already contains the pattern for it:
the four /users/:id routes apply middleware.AuthMiddleware() inline as an
argument to each router.GET. So the correct idiom is present twice in one file,
and the two WebSockets missed it.
Reassembling the pipeline
- Parse
weightsfrom the query string: 400 if unparseable, otherwise unvalidated - Haversine filter: must be inside the smaller of the two radii
- Drop the caller, existing connections, and previously dismissed users
- Apply both gates
- Score the survivors across ten attributes
DELETEevery recommendation row for the user, then re-create them with scores
Step 6 means a GET /recommendations is not read-only. It rewrites a table on every
call, before the pagination that ignores the ordering it just computed.
Profiles and realtime integration
The React client connects profile setup and editing to the API's stored preferences, location and account data. The team also integrated chat, connection updates, notifications and online presence. The contribution description separates my backend/frontend integration work from teammates' substantial realtime and UI work.
These features describe the implementation, not a claim of a production deployment or complete access-control coverage. The WebSocket registration gap above remains.
Reference data
/options/* exposes ten dropdown vocabularies, and /options/cities serves
47,868 rows from worldcities.csv for the location picker. The games list is
49 entries. POST /register and POST /login are the only two endpoints that are
fully unauthenticated apart from the WebSocket gap above.
Interactive models
Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.
API routes and authentication boundaries
ApiExplorer
43 endpoints, grouped by auth state.
43 of 43 shown
- auth4
- protected20
- public17
- unguarded-ws2
client:visible
Stack and source figures
how it fits together
- backend
-
-
Go 1.22 + Gin43 registered routes across four auth states, with group middleware applied where WebSockets registered outside their group ended up unguarded.
-
- matching
-
-
Weighted attribute scoringTen attributes, base points multiplied by a client-supplied weight, halving per additional match. Two of the ten are hard `continue` gates rather than weights. A candidate with no shared game is never scored at all. -
Haversine in GoProximity within the smaller of the two users' radii, computed in process. A PostGIS GIST index exists in the migrations and nothing queries it.
-
- data
-
-
PostgreSQL + PostGIS21 tables, 47,868 cities in worldcities.csv for the location picker.
-
- auth
-
-
JWT v5 + bcryptRegistration, login and a protected route set gated by middleware.
-
- realtime
-
-
gorilla/websocketFour channels: notifications, connections, presence, and live chat.
-
- frontend
-
-
ReactRequests `?limit=20` and renders 10, using the client weight object to reshape the ranking between fetches.
-
- registered routes
- 43 Plus one commented out after it moved. Two of them are WebSockets with no auth middleware.
- tables
- 21
- city rows
- 47,868 Data rows in worldcities.csv. The earlier count of 47,869 included the header line.
- preference attributes
- 10 Two of them are hard gates rather than weights.
- commits
- 194 2024-12-28 to 2026-06-10.
Full issue record · 3 entries
what broke, and what happened
- 3 unresolved / 3
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- unresolved
The ranking is sorted, then thrown away by a map
`RankUsersByPreference` sorts `scoredUsers` with `sort.Slice`, returns `map[uuid.UUID]float64`, and `ProcessRecommendations` rebuilds the ID list by ranging over that map. Go randomises map iteration order, so the order is gone before it leaves the function.
then: Unresolved. Still a map return at `filter.go:228`, so the sort survives one statement and is then discarded. The handler paginates the random order that replaces it.
- unresolved
Two chat WebSockets are registered outside their protected group
`GET /chats/:id/ws` and `GET /ws/chats-update` are registered after `chatRoutes` and `connectionRoutes` close, so no middleware applies.
then: Present in the reviewed source at `routes.go:105` and `:107`. If run as written, the chat endpoint allows access without a token to a caller who knows the chat id.
- unresolved
A PostGIS GIST index that nothing queries
`CREATE INDEX idx_locations_coordinates ON locations USING GIST (...)`; `FilterUsersByLocation` computes Haversine in Go over every user.
then: Unresolved by choice: the index is pure write cost, since proximity is an in-process loop over the full user set with nothing to query spatially.