April 15, 2024 • 10 min read
I built Hangtime because I was sick of turning up to courts with no one there. It's a side project. A Melbourne app for finding pickup basketball games. It's at about 10,000 players now, almost all of that word of mouth. I'll cover the engineering, but the part that actually kept it alive was the boring stuff — the community, the support emails, deciding what not to build.
React Native so I wasn't maintaining two apps. Firebase so I wasn't running servers. When it's just you, those two decisions are most of the battle.
Melbourne has hundreds of courts and plenty of people who want a game. The two don't find each other. You rock up hoping for a run and the court's dead. Or you hear later that a good game was on at a court you didn't think to check.
I built it for me first. I knew what was annoying because I'd lived it. Turned out other people had the same problem.
The map is the bit people open the app for. Courts with a live game on show up in green, dead ones in grey. That's it — but it's the whole pitch in one screen:
// Court Map Component with Real-time Updates
import React, { useState, useEffect } from 'react';
import MapView, { Marker, Callout } from 'react-native-maps';
import firestore from '@react-native-firebase/firestore';
import { useNavigation } from '@react-navigation/native';
const CourtMapScreen = () => {
const [courts, setCourts] = useState([]);
const [activeGames, setActiveGames] = useState([]);
const navigation = useNavigation();
useEffect(() => {
// Load basketball courts from Firestore
const unsubscribeCourts = firestore()
.collection('courts')
.onSnapshot(snapshot => {
const courtsData = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setCourts(courtsData);
});
// Listen to active games in real-time
const unsubscribeGames = firestore()
.collection('games')
.where('status', '==', 'active')
.where('date', '>=', new Date())
.onSnapshot(snapshot => {
const gamesData = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
setActiveGames(gamesData);
});
return () => {
unsubscribeCourts();
unsubscribeGames();
};
}, []);
const getActiveGameForCourt = (courtId) => {
return activeGames.find(game => game.courtId === courtId);
};
return (
<MapView
style={{ flex: 1 }}
initialRegion={{
latitude: -37.8136,
longitude: 144.9631,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
}}
>
{courts.map(court => {
const activeGame = getActiveGameForCourt(court.id);
return (
<Marker
key={court.id}
coordinate={{
latitude: court.location.latitude,
longitude: court.location.longitude,
}}
pinColor={activeGame ? '#22c55e' : '#6b7280'}
>
<Callout onPress={() => navigation.navigate('CourtDetail', {
courtId: court.id
})}>
<View style={{ padding: 10, minWidth: 200 }}>
<Text style={{ fontWeight: 'bold', fontSize: 16 }}>
{court.name}
</Text>
<Text style={{ color: '#666', marginTop: 4 }}>
{court.address}
</Text>
{activeGame && (
<View style={{
marginTop: 8,
padding: 8,
backgroundColor: '#22c55e',
borderRadius: 4
}}>
<Text style={{ color: 'white', fontWeight: 'bold' }}>
🏀 Active Game
</Text>
<Text style={{ color: 'white', fontSize: 12 }}>
{activeGame.players.length}/{activeGame.maxPlayers} players
</Text>
</View>
)}
</View>
</Callout>
</Marker>
);
})}
</MapView>
);
};
export default CourtMapScreen;Firestore does the live updates. The one spot I had to be careful was joining a game. Two people tapping "join" on the last spot at the same time can't both get in, so it runs in a transaction:
// Game Service - Creating and Managing Basketball Games
import firestore from '@react-native-firebase/firestore';
import messaging from '@react-native-firebase/messaging';
class GameService {
static async createGame(gameData) {
const { courtId, date, time, maxPlayers, description, createdBy } = gameData;
try {
const gameRef = await firestore().collection('games').add({
courtId,
date: firestore.Timestamp.fromDate(new Date(date)),
time,
maxPlayers,
description,
createdBy,
players: [createdBy], // Creator automatically joins
status: 'active',
createdAt: firestore.FieldValue.serverTimestamp(),
updatedAt: firestore.FieldValue.serverTimestamp()
});
// Send notification to nearby players
await this.notifyNearbyPlayers(courtId, gameRef.id);
return gameRef.id;
} catch (error) {
console.error('Error creating game:', error);
throw error;
}
}
static async joinGame(gameId, userId) {
const gameRef = firestore().collection('games').doc(gameId);
try {
await firestore().runTransaction(async (transaction) => {
const gameDoc = await transaction.get(gameRef);
if (!gameDoc.exists) {
throw new Error('Game not found');
}
const gameData = gameDoc.data();
if (gameData.players.includes(userId)) {
throw new Error('Already joined this game');
}
if (gameData.players.length >= gameData.maxPlayers) {
throw new Error('Game is full');
}
transaction.update(gameRef, {
players: firestore.FieldValue.arrayUnion(userId),
updatedAt: firestore.FieldValue.serverTimestamp()
});
// Add to user's joined games
transaction.update(
firestore().collection('users').doc(userId),
{
joinedGames: firestore.FieldValue.arrayUnion(gameId)
}
);
});
// Notify other players
await this.notifyGamePlayers(gameId, `New player joined the game!`);
} catch (error) {
console.error('Error joining game:', error);
throw error;
}
}
static async notifyNearbyPlayers(courtId, gameId) {
// Get users who have played at this court before
const nearbyUsers = await firestore()
.collection('users')
.where('favoriteCourts', 'array-contains', courtId)
.get();
const tokens = nearbyUsers.docs
.map(doc => doc.data().fcmToken)
.filter(token => token);
if (tokens.length > 0) {
await messaging().sendMulticast({
tokens,
notification: {
title: '🏀 New Game Started!',
body: 'A basketball game is starting near your favorite court'
},
data: {
type: 'new_game',
gameId,
courtId
}
});
}
}
static async updateGameStatus(gameId, status) {
await firestore().collection('games').doc(gameId).update({
status,
updatedAt: firestore.FieldValue.serverTimestamp()
});
if (status === 'completed') {
// Update player statistics
const game = await firestore().collection('games').doc(gameId).get();
const gameData = game.data();
for (const playerId of gameData.players) {
await firestore().collection('users').doc(playerId).update({
gamesPlayed: firestore.FieldValue.increment(1),
lastGameDate: firestore.FieldValue.serverTimestamp()
});
}
}
}
}
export default GameService;Every game gets a chat thread. Sounds minor. It's where half the coordination happens — running late, bringing a mate, calling it off when it's raining. GiftedChat over a Firestore subcollection, didn't need anything fancier:
// Chat Component for Game Coordination
import React, { useState, useEffect, useCallback } from 'react';
import { GiftedChat } from 'react-native-gifted-chat';
import firestore from '@react-native-firebase/firestore';
import auth from '@react-native-firebase/auth';
const GameChatScreen = ({ route }) => {
const { gameId } = route.params;
const [messages, setMessages] = useState([]);
const currentUser = auth().currentUser;
useEffect(() => {
const unsubscribe = firestore()
.collection('games')
.doc(gameId)
.collection('messages')
.orderBy('createdAt', 'desc')
.limit(50)
.onSnapshot(snapshot => {
const messagesData = snapshot.docs.map(doc => {
const data = doc.data();
return {
_id: doc.id,
text: data.text,
createdAt: data.createdAt.toDate(),
user: {
_id: data.user._id,
name: data.user.name,
avatar: data.user.avatar
}
};
});
setMessages(messagesData);
});
return unsubscribe;
}, [gameId]);
const onSend = useCallback((messages = []) => {
const message = messages[0];
firestore()
.collection('games')
.doc(gameId)
.collection('messages')
.add({
text: message.text,
createdAt: firestore.FieldValue.serverTimestamp(),
user: {
_id: currentUser.uid,
name: currentUser.displayName,
avatar: currentUser.photoURL
}
});
}, [gameId, currentUser]);
return (
<GiftedChat
messages={messages}
onSend={onSend}
user={{
_id: currentUser.uid,
name: currentUser.displayName,
avatar: currentUser.photoURL
}}
placeholder="Type a message..."
showUserAvatar
alwaysShowSend
/>
);
};
export default GameChatScreen;Growth was word of mouth, but I put a bit of scaffolding behind it. A reputation score, skill-based matching, court recommendations. Nothing clever. Just small nudges that reward showing up and make the next game easier to find:
// User Analytics & Growth Tracking
const UserAnalytics = {
// Track user engagement metrics
trackUserAction: async (userId, action, metadata = {}) => {
await firestore().collection('analytics').add({
userId,
action,
metadata,
timestamp: firestore.FieldValue.serverTimestamp(),
appVersion: getAppVersion(),
platform: Platform.OS
});
},
// Community building features
calculateUserReputation: async (userId) => {
const userDoc = await firestore().collection('users').doc(userId).get();
const userData = userDoc.data();
let reputation = 0;
// Points for games played
reputation += userData.gamesPlayed * 2;
// Points for games organized
reputation += userData.gamesOrganized * 5;
// Points for positive reviews
reputation += userData.positiveReviews * 3;
// Bonus for consistent play
const daysActive = userData.activeDays || 0;
if (daysActive > 30) reputation += 20;
if (daysActive > 90) reputation += 50;
return reputation;
},
// Growth metrics
getGrowthMetrics: async () => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const newUsers = await firestore()
.collection('users')
.where('createdAt', '>=', thirtyDaysAgo)
.get();
const activeGames = await firestore()
.collection('games')
.where('date', '>=', thirtyDaysAgo)
.get();
const totalUsers = await firestore()
.collection('users')
.get();
return {
newUsersThisMonth: newUsers.size,
gamesThisMonth: activeGames.size,
totalUsers: totalUsers.size,
avgGamesPerUser: activeGames.size / totalUsers.size
};
}
};
// Community Features
const CommunityFeatures = {
// Player skill matching
findCompatiblePlayers: async (userId) => {
const user = await firestore().collection('users').doc(userId).get();
const userSkill = user.data().skillLevel;
return firestore()
.collection('users')
.where('skillLevel', '>=', userSkill - 1)
.where('skillLevel', '<=', userSkill + 1)
.where('isActive', '==', true)
.limit(20)
.get();
},
// Court recommendations
recommendCourts: async (userId) => {
const userGames = await firestore()
.collection('games')
.where('players', 'array-contains', userId)
.get();
const playedCourts = userGames.docs.map(doc => doc.data().courtId);
// Recommend courts similar players use
const similarPlayerCourts = await firestore()
.collection('courts')
.where('popularWith', 'array-contains-any', playedCourts)
.limit(10)
.get();
return similarPlayerCourts.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
}
};Development Timeline: Month 1-2: MVP development (core features) Month 3-4: Beta testing with local basketball groups Month 5-6: App Store launch and initial user acquisition Month 7-12: Feature expansion based on user feedback Month 13+: Sustainable growth and community building Growth Strategy: 🏀 Partner with local basketball courts and leagues 👥 User referral system and community events 📱 App Store optimization and social media presence 🎯 Focus on user retention over acquisition Key Metrics: • 10,000+ active monthly users • 4.8/5 average App Store rating • 85% weekly user retention rate • 5,000+ games successfully organized • Featured in local Melbourne sports media
It's the project I'm fondest of, mostly because I still use it and so do my mates. The code wasn't the hard part. Answering support emails on a Sunday was the hard part. It's still going because I care about it, which is not much of a strategy but it's the honest answer.