Mobile AppBasketballReact Native

Building Hangtime: basketball meets software

April 15, 2024 • 10 min read

What the app does, and what it runs on

  • React Native – one codebase for iOS and Android
  • A map – 200+ Melbourne courts on it
  • Profiles and games – so people can actually organise something
  • Pickup matching – find players, fill a game

Where it got to

  • 10,000+ players – mostly word-of-mouth, no ad budget
  • 5,000+ games organised – games that actually happened
  • 4.8/5 on the App Store
  • Across Melbourne – the major courts are all on there

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.

How it's put together

Mobile App Layer (React Native)
iOS App
Android App
Maps
Chat
Profile
↕️
Backend Services (Node.js + Firebase)
REST API
Push Notifications
Firebase Auth & Firestore
Real-time Chat
Location Services
↕️
Data & External Services
Google Maps
Firebase Storage
Analytics

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.

The actual problem

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.

Before Hangtime
• Empty courts or packed games
• No way to find other players
• Inconsistent pickup games
• Limited social connections
After Hangtime
• Organized games with confirmed players
• Real-time court activity
• Strong basketball community
• Lasting friendships formed

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 screen

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;

Creating and joining games

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;

Chat per game

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;

Nudging people to come back

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()
    }));
  }
};

The business side

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.

Download:App Store
Share: