April 15, 2024 β’ 10 min read
Combining passion for basketball with engineering skills to solve real problems. Hangtime Melbourne connects over 10,000 basketball players across Melbourne, but the real story is how we built a community-driven platform that scaled organically through word-of-mouth and solid engineering practices. This project taught me that the best software solutions come from solving problems you're passionate about.
React Native enabled cross-platform development while Firebase provided real-time features and scalable backend infrastructure.
Melbourne has hundreds of basketball courts, but finding active players and games was always a challenge. Players would show up to empty courts or miss out on games because they didn't know where the action was.
By solving a real problem I personally experienced, Hangtime grew organically to become Melbourne's go-to basketball community app.
React Native allowed us to build for both iOS and Android with a single codebase while maintaining native performance:
// 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;
Firebase Firestore enables real-time game updates and player coordination:
// 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;
In-app messaging enables players to coordinate and build community:
// 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;
Organic growth through community-first features and basketball culture:
// 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
Building Hangtime taught me that the most successful projects combine technical skills with genuine passion. When you're solving a problem you care about, the long hours of coding, debugging, and user support don't feel like workβthey feel like building something meaningful for your community.