A Weekend Adventure

Liad Yosef 

Liad Yosef

Client Architect @ Duda

If you build it, they will come

Wakanda Forever

useSideProject

const game = { cards: 5, players: 4 }
if(firebase.ready() === true) {
  videoChat.run()
}

3

5

2

2

3

4

0

1

0

2

0

4

3

5

2

2

3

4

0

1

0

2

0

4

Chapter 1 - Planning

3

5

2

2

3

4

0

1

0

2

0

4

Chapter 2 - Setting up

Let there be light!

npx create-react-app splendor
.root {
   background: url('./assets/table.png');
   ...
}
.root {
   background: url('./assets/table.png');
   ...
}
.main {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  grid-template-areas:
    "game hand hand"
    "game players chat";
}

Hand

Game

Players

Chat

.gameTable {
  grid-area: game;
}
.playerHand {
  grid-area: hand;
}
.players {
  grid-area: players;
}
.chat {
  grid-area: chat;
}

Hand

Game

Players

Chat

function Main() {
  return (
    <div className={styles.main}>
      <Game className={styles.gameTable}></Game>
      <Hand className={styles.playerHand}></Hand>
      <Players className={styles.players}></Players>
      <Chat className={styles.chat}></Chat>
    </div>
  );
}

Many design iterations later....

Players

Chat

Game

3

5

2

2

3

4

Hand

0

1

0

2

0

4

3

5

2

2

3

4

function Board({ nobles, cardArray, chips }) {
  return <>
    <Nobles nobles={nobles}/>
    <Cards cardArray={cardArray}/>
    <Chips chips={chips}/>
  </>
}

function Cards({ cardArray }) {
  return cardArray.map(({ cardData }) => <Card {...cardData} />);
}

function Card({ image, points, price }) {
  const style = { backgroundImage: `url('${image}')` };
  return (
    <div className={styles.card} style={style}>
      <div className={styles.cardPoints}>
        {points}/{price}
      </div>
    </div>
  );
}
function Board({ nobles, cardArray, chips }) {
  return <>
    <Nobles nobles={nobles}/>
    <Cards cardArray={cardArray}/>
    <Chips chips={chips}/>
  </>
}

function Cards({ cardArray }) {
  return cardArray.map(({ cardData }) => <Card {...cardData} />);
}

function Card({ image, points, price }) {
  const style = { backgroundImage: `url('${image}')` };
  return (
    <div className={styles.card} style={style}>
      <div className={styles.cardPoints}>
        {points}/{price}
      </div>
    </div>
  );
}

3

5

2

2

3

4

function Board({ nobles, cardArray, chips }) {
  return <>
    <Nobles nobles={nobles}/>
    <Cards cardArray={cardArray}/>
    <Chips chips={chips}/>
  </>
}

function Cards({ cardArray }) {
  return cardArray.map(({ cardData }) => <Card {...cardData} />);
}

function Card({ image, points, price }) {
  const style = { backgroundImage: `url('${image}')` };
  return (
    <div className={styles.card} style={style}>
      <div className={styles.cardPoints}>
        {points}/{price}
      </div>
    </div>
  );
}

Players

Chat

3

5

2

2

3

4

0

1

0

2

0

4

Chapter 3 - Interactivity

function Board({ nobles, cardArray, chips }) {
  return <>
    <Nobles nobles={nobles}/>
    <Cards cardArray={cardArray}/>
    <Chips chips={chips}/>
  </>
}

function Cards({ cardArray }) {
  return cardArray.map(({ cardData }) => <Card {...cardData} />);
}

function Card({ image, points, price }) {
  const style = { backgroundImage: `url('${image}')` };
  return (
    <div className={styles.card} style={style}>
      <div className={styles.cardPoints}>
        {points}/{price}
      </div>
    </div>
  );
}

From where?

Let's add it to our app!

Let's add it to our app!

npm install --save firebase

Let's add it to our app!

import { init as initFirebase } '../firebase-storage';

function Main() {
  useEffect(() => {
    initFirebase();
  }, []);
  return <div className={styles.main}>
    // our app
  </div>
}

Now our app is real-time and serverless!

Firebase API

firebase
  .database()
  .ref('players/g1htz3/pic')
  .set('https://www.imgbb.com/liadyosef.png');

Writing data

firebase
  .database()
  .ref('players/g1htz3/pic')
  .on('value', snapshot => {
    console.log(snapshot.val());
  });

Reading data

Firebase API

firebase
  .database()
  .ref('players/g1htz3/pic')
  .set('https://www.imgbb.com/liadyosef.png');

Firebase API

firebase
  .database()
  .ref()
  .child('players/g1htz3/pic')
  .set('https://www.imgbb.com/liadyosef.png');

Firebase API

firebase
  .database()
  .ref()
  .child('players/g1htz3')
  .update({
    pic: 'https://www.imgbb.com/liadyosef.png'
   });

Firebase API

firebase
  .database()
  .ref()
  .child('settings/gameDate')
  .set(firebase.database.ServerValue.TIMESTAMP);

Firebase API

firebase
  .database()
  .ref()
  .child('hands/g1htz/cards')
  .on('value', snapshot => {
    console.log(snapshot.value())
  });

Helper functions

export function changeValue(key, newValue, type = ChangeMode.SET) {
  const ref = firebase.database().ref().child(key);
  if(type === ChangeMode.UPDATE) {
    return ref.update(newValue);
  }
  return ref.set(newValue);
}

export function onValue(key, callback) {
  const unsubscribe = firebase.database()
    .ref()
    .child(key)
    .on('value', (snapshot) => {
      callback(snapshot?.val());
    });
  return unsubscribe;
}

Helper functions

import { changeValue, onValue } from '../firebase-storage';

// later...

changeValue(`settings/allowedPlayers`, 5);

changeValue(`players/${playerId}/pic`, newProfilePic);

onValue(`hands/${playerId}/chips`, chips => doSomething(chips));

It can get messy, fast

Define a mental scheme

Players

3

5

2

2

3

4

0

1

0

2

0

4

Chat

0

1

0

2

0

4

3

5

2

2

3

4

export const Schema = shape({
  tableData: shape({
    cards: arrayOf([arrayOf([cardID])]),
    chips: objectOf({
      [chipColor]: int,
    }),
  }),
  playerHands: objectOf({
    [playerId]: shape({
      cards: arrayOf([cardID]),
      chips: objectOf({
        [chipColor]: int,
      }),
    }),
  }),
  players: objectOf({
    [userUUID]: shape({
      name: string,
      profilePic: string,
    }),
  }),
  gameData: shape({
    gameStage: oneOf(['OPEN', 'DONE']),
    maxPlayers: int,
    currentPlayer: userUUID,
  }),
  cards: objectOf({
    [cardID]: shape({
      image: string,
      points: int,
      gem: oneOf(['WHITE', 'RED', 'GREEN', 'BLUE', 'BROWN']),
      price: int,
    }),
  }),
});
export const Schema = shape({
  ...
  cards: objectOf({
    [cardID]: shape({
      image: string,
      points: int,
      gem: oneOf(['WHITE', 'RED', 'GREEN', 'BLUE', 'BROWN']),
      price: int,
    }),
  }),
});
function Card({ cardId }) {
  const [cardData, setCardData] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`cards/${cardId}`, card => {
      setCardData(card);
    })
    return unsubscribe;
  }, [cardId]);
  return cardData ? <div className={styles.card}>
    <img src={cardData.image}/>
    {cardData.points} / {cardData.price}
  </div> : null;
}
function Card({ cardId }) {
  const [cardData, setCardData] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`cards/${cardId}`, card => {
      setCardData(card);
    })
    return unsubscribe;
  }, [cardId]);
  return cardData ? <div className={styles.card}>
    <img src={cardData.image}/>
    {cardData.points} / {cardData.price}
  </div> : null;
}

3

5

2

2

3

4

function Chip({ chipColor }) {
  const [chipQuantity, setChipQuantity] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`tableData/chips/${chipColor}`, (chipNum) => {
      setChipQuantity(chipNum);
    });
    return unsubscribe;
  }, [chipColor]);
  return (
    <div className={styles.chip}>
      <div className={styles.chipImage} style={{ color: chipColor }}></div>
      <div className={styles.chipQuant}>{chipQuantity}</div>
    </div>
  );
}
function Card({ cardId }) {
  const [cardData, setCardData] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`cards/${cardId}`, card => {
      setCardData(card);
    })
    return unsubscribe;
  }, [cardId]);
  return cardData ? <div className={styles.card}>
    <img src={cardData.image}/>
    {cardData.points} / {cardData.price}
  </div> : null;
}
function Chip({ chipColor }) {
  const [chipQuantity, setChipQuantity] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`tableData/chips/${chipColor}`, (chipNum) => {
      setChipQuantity(chipNum);
    });
    return unsubscribe;
  }, [chipColor]);
  return (
    <div className={styles.chip}>
      <div className={styles.chipImage} style={{ color: chipColor }}></div>
      <div className={styles.chipQuant}>{chipQuantity}</div>
    </div>
  );
}
function CurrentPlayer() {
  const [currentPlayer, setCurrentPlayer] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`gameData/currentPlayer`, (playerId) => {
        setCurrentPlayer(playerId);
      });
      return unsubscribe;
  }, []);
  return <div className={styles.currentPlayerWrapper}>
      `Current player is: ${getPlayerName(playerId)}`
  </div>
}
function Card({ cardId }) {
  const [cardData, setCardData] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`cards/${cardId}`, card => {
      setCardData(card);
    })
    return unsubscribe;
  }, [cardId]);
  return cardData ? <div className={styles.card}>
    <img src={cardData.image}/>
    {cardData.points} / {cardData.price}
  </div> : null;
}
function Chip({ chipColor }) {
  const [chipQuantity, setChipQuantity] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`tableData/chips/${chipColor}`, (chipNum) => {
      setChipQuantity(chipNum);
    });
    return unsubscribe;
  }, [chipColor]);
  return (
    <div className={styles.chip}>
      <div className={styles.chipImage} style={{ color: chipColor }}></div>
      <div className={styles.chipQuant}>{chipQuantity}</div>
    </div>
  );
}
function CurrentPlayer() {
  const [currentPlayer, setCurrentPlayer] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`gameData/currentPlayer`, (playerId) => {
        setCurrentPlayer(playerId);
      });
      return unsubscribe;
  }, []);
  const changePlayer = useCallback((newPlayerId) => {
    changeValue(`gameData/currentPlayer`, newPlayerId);
  }, [])
  return <div className={styles.currentPlayerWrapper}>
      `Current player is: ${getPlayerName(playerId)}`
      <button onClick={changePlayer}>Change</button>
  </div>
}
export function useFirebase(key, initialValue, { type = ChangeMode.SET } = {}) {
  const [valueObject, setValueObject] = useState({ value: initialValue, status: PENDING_VALUE });
  const [defaultVal] = useState(initialValue);

  useEffect(() => {
    const unsubscribe = onValue(key, (snapshotVal) => {
      let status, val;
      if (snapshotVal === null || snapshotVal === undefined) {
        status = MISSING_VALUE;
        val = defaultVal;
      } else {
        status = FOUND_VALUE;
        val = snapshotVal;
      }
      setValueObject({ value: val, status });
    });
    return unsubscribe;
  }, [key, defaultVal]);

  const changeFirebaseValue = useCallback(
    (newValue, writeTo = key) => {
      return changeValue(writeTo, newValue, type);
    },
    [key, type]
  );

  return [valueObject.value, changeFirebaseValue, valueObject.status];
}
function Card({ cardId }) {
  const [cardData, setCardData] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`cards/${cardId}`, card => {
      setCardData(card);
    })
    return unsubscribe;
  }, [cardId]);
  return cardData ? <div className={styles.card}>
    <img src={cardData.image}/>
    {cardData.points} / {cardData.price}
  </div> : null;
}
function Chip({ chipColor }) {
  const [chipQuantity, setChipQuantity] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`tableData/chips/${chipColor}`, (chipNum) => {
      setChipQuantity(chipNum);
    });
    return unsubscribe;
  }, [chipColor]);
  return (
    <div className={styles.chip}>
      <div className={styles.chipImage} style={{ color: chipColor }}></div>
      <div className={styles.chipQuant}>{chipQuantity}</div>
    </div>
  );
}
function CurrentPlayer() {
  const [currentPlayer, setCurrentPlayer] = useState(null);
  useEffect(() => {
    const unsubscribe = onValue(`gameData/currentPlayer`, (playerId) => {
        setCurrentPlayer(playerId);
      });
      return unsubscribe;
  }, []);
  const changePlayer = useCallback((newPlayerId) => {
    changeValue(`gameData/currentPlayer`, newPlayerId);
  }, [])
  return <div className={styles.currentPlayerWrapper}>
      `Current player is: ${getPlayerName(playerId)}`
      <button onClick={changePlayer}>Change</button>
  </div>
}
function Card({ cardId }) {
  const [cardData] = useFirebase(`cards/${cardId}`);
  return cardData ? <div className={styles.card}>
    <img src={cardData.image}/>
    {cardData.points} / {cardData.price}
  </div> : null;
}
function Chip({ chipColor }) {
  const [chipQuantity] = useFirebase(`tableData/chips/${chipColor}`);
  return (
    <div className={styles.chip}>
      <div className={styles.chipImage} style={{ color: chipColor }} />
      <div className={styles.chipQuant}>{chipQuantity}</div>
    </div>
  );
}
function CurrentPlayer() {
  const [currentPlayer, changePlayer] = useFirebase(`gameData/currentPlayer`);
  return <div className={styles.currentPlayerWrapper}>
      `Current player is: ${getPlayerName(playerId)}`
      <button onClick={changePlayer}>Change</button>
  </div>
}
function Nobles() {
  const [nobles] = useFirebase(`tableData/nobles`, []);
  return (
    <div className={styles.noblesWrapper}>
      {nobles.map(nobleId => <Noble id={nobleId} />)}
    </div>
  );
}
export const Schema = shape({
  tableData: shape({
    cards: arrayOf([arrayOf([cardID])]),
    chips: objectOf({
      [chipColor]: int,
    }),
    nobles: arrayOf([nobleId])
  }),
  nobles: objectOf({
    [nobleId]: shape({
      image: string,
      points: int
    })
  }),
  ...
function usePlayerScore(playerId) {
  const [playerData, , status] = useFirebase(`playerHands/${playerId}`, {});
  const [cards] = useFirebase('cards', {});
  const cardScore = useMemo(
    () =>
      playerData.cards.reduce((acc, cardId) => {
        return acc + cards[cardId]?.points;
      }, 0),
    [playerData, cards]
  );
  return [cardScore, null, status];
}
function PlayerView(playerId) {
  const [playerScore, , status] = usePlayerScore(playerId);
  const [playerName] = usePlayerName(playerId);
  return status === PENDING ? null : (
    <div className={styles.playerView}>
      {playerName}: {playerScore}
    </div>
  );
}

0

1

0

2

0

4

Chapter 4 - Chat

3

5

2

2

3

4

0

1

0

2

0

4

3

5

2

2

3

4

0

1

0

2

0

4

npm install --save react-chat-elements
    maxPlayers: int,
    currentPlayer: userUUID,
  }),
  chat: shape({
    messages: arrayOf(
      shape({
        content: string,
        timestamp: int,
        poster: string,
      })
    ),
  }),
  cards: objectOf({
    [cardID]: shape({
      image: string,
      points: int,
    maxPlayers: int,
    currentPlayer: userUUID,
  }),
  chat: shape({
    messages: arrayOf(
      shape({
        content: string,
        timestamp: int,
        poster: string,
      })
    ),
  }),
  cards: objectOf({
    [cardID]: shape({
      image: string,
      points: int,
function Chat() {
  const [messages, pushMessage] = useFirebase('chat/messages', [], { type: ChangeMode.PUSH });
  const [currentMessage, setCurrentMessage] = useState();

  const handleSend = useCallback(() => {
    const content = {
      content: currentMessage,
      timestamp: getServerTimeStamp(),
      poster: gameState.userId,
    };
    pushMessage(content);
  }, [currentMessage, pushMessage]);
  
  return (
    <>
      <MessageList className={styles.messageList} lockable={true} dataSource={messages} />
      <Input
        onChange={setCurrentMessage}
        placeholder='Enter message here...'
        rightButtons={<Button color='white' backgroundColor='black' text='Send' onClick={handleSend} />}
      />
    </>
  );
}

3

5

2

2

3

4

0

1

0

2

0

4

Chapter 5 - Video

3

5

2

2

3

4

0

1

0

2

0

4

Agora to the rescue!

Agora to the rescue!

npm install --save agora-rtc-sdk

1. Create client

2. Join Channel

3. Create Stream

4. Publish stream

5. Listen to events

this.rtcClient = AgoraRTC.createClient({ mode: 'rtc', codec: 'h264' });
this.rtcClient.join(token, channelId, uid);
this.localStream = AgoraRTC.createStream({ streamID: this.uid });
this.rtcClient.publish(this.localStream);
this.rtcClient.on('stream-added', (evt) => {
  this.rtcClient.subscribe(evt.stream);
});
this.rtcClient.on('stream-subscribed', (evt) => {
  evt.stream.play('remote_video_' + evt.stream.getId());
});
export default function Players() {
  const [players] = useFirebase('players', {});
  return (
    <div className={styles.participants}>
      <div className={styles.videoGrid}>
        <LocalVideo id='local'/>
        {Object.entries(players).map(([userId]) => {
          return <Participant id={userId} />;
        })}
      </div>
    </div>
  );
}
export class Agora {
    
  createClient() {
    this.rtcClient = AgoraRTC.createClient({ mode: 'rtc', codec: 'h264' });
  }
  
  hangEvents() {
    this.rtcClient.on('stream-added', (evt) => {
      const remoteStream = evt.stream;
      const id = remoteStream.getId();
      this.rtcClient.subscribe(remoteStream);
    });
    this.rtcClient.on('stream-subscribed', (evt) => {
      const remoteStream = evt.stream;
      const id = remoteStream.getId();
      this.remoteStreams[id] = remoteStream;
      remoteStream.play('remote_video_' + id);
    });
  }
  
  join(channelId, uid = null, token = null) {
    this.uid = uid;
    this.channelId = channelId;
    return new Promise((resolve) => {
      this.rtcClient.join(token, channelId, uid, () => {
        this.localStream = AgoraRTC.createStream({ streamID: this.uid });
        this.localStream.init(() => {
          this.localStream.play('local_stream');
          this.rtcClient.publish(this.localStream);
          resolve();
        });
      });
    });
  }
}
export default function Players() {
  
  useEffect(() => {
    setTimeout(async () => {
      await agora.init();
      await agora.hangEvents();
      await agora.join('splendor_game', gameState.userId);
    });
    return () => {
      agora.leave();
    };
  }, []);
  
  const [players] = useFirebase('players', {});
  return (
    <div className={styles.participants}>
      <div className={styles.videoGrid}>
        <LocalVideo id='local'/>
        {Object.entries(players).map(([userId]) => {
          return <Participant id={userId} />;
        })}
      </div>
    </div>
  );
}
export class Agora {
    
  createClient() {
    this.rtcClient = AgoraRTC.createClient({ mode: 'rtc', codec: 'h264' });
  }
  
  hangEvents() {
    this.rtcClient.on('stream-added', (evt) => {
      const remoteStream = evt.stream;
      const id = remoteStream.getId();
      this.rtcClient.subscribe(remoteStream);
    });
    this.rtcClient.on('stream-subscribed', (evt) => {
      const remoteStream = evt.stream;
      const id = remoteStream.getId();
      this.remoteStreams[id] = remoteStream;
      remoteStream.play('remote_video_' + id);
    });
  }
  
  join(channelId, uid = null, token = null) {
    this.uid = uid;
    this.channelId = channelId;
    return new Promise((resolve) => {
      this.rtcClient.join(token, channelId, uid, () => {
        this.localStream = AgoraRTC.createStream({ streamID: this.uid });
        this.localStream.init(() => {
          this.localStream.play('local_stream');
          this.rtcClient.publish(this.localStream);
          resolve();
        });
      });
    });
  }
}

Agora provides a lot
of API methods -
and events - to control the video stream

Muting audio

function LocalVideo() {
  return (
    <div className={styles.localStreamView}>
      <div id='local_stream' className={styles.videoPlaceholder} />
    </div>
  );
}
class Agora {
...
  toggleSelfAudio(audio) {
    if (audio) {
      this.localStream.unmuteAudio();
    } else {
      this.localStream.muteAudio();
    }
  }
  ...
}

Muting audio

function LocalVideo() {
  const toggleAudio = useCallback((value) => {
    agora.toggleSelfAudio(value);
  }, []);
  return (
    <div className={styles.localStreamView}>
      <div id='local_stream' className={styles.videoPlaceholder} />
      <Flex center className={styles.toggleLocalAudio} onClick={toggleAudio}>
        {agora.audio ? <Icon icon={faMicrophone} /> : <Icon icon={faMicrophoneSlash} />}
      </Flex>
    </div>
  );
}

New API (beta)

3

5

2

2

3

4

0

1

0

2

0

4

3

5

2

2

3

4

0

1

0

2

0

4

Chapter 6 - Deploy

3

5

2

2

3

4

0

1

0

2

0

4

3

5

2

2

3

4

0

1

0

2

0

4

Stage 7 -
And more!

Authentication

import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth';
import 'firebase/auth';

export default function Login() {
  const uiConfig = {
    signInFlow: 'popup',
    signInOptions: [firebase.auth.EmailAuthProvider.PROVIDER_ID, firebase.auth.GoogleAuthProvider.PROVIDER_ID],
    credentialHelper: 'none',
    callbacks: {
      signInSuccessWithAuthResult: (authResult) => {
        signIn({ user: authResult.user });
        return false;
      },
    },
  };
  return (
    <Flex column center className={styles.main}>
      <div>Greetings! Tell us who you are...</div>
      <StyledFirebaseAuth uiConfig={uiConfig} firebaseAuth={firebase.auth()} />
    </Flex>
  );
}
export function signIn({ user }) {
  gameState.setUserAuth(new User(user));
  user.getIdTokenResult().then((idTokenResult) => {
    gameState.setAdmin(!!idTokenResult?.claims?.admin);
    gameState.initAuth();
  });
}

3

5

2

2

3

4

0

1

0

2

0

4

Multiple Games

Multiple Games

export function changeValue(key, newValue, type = ChangeMode.SET) {
  const ref = firebase.database().ref().child(key);
  if(type === ChangeMode.UPDATE) {
    return ref.update(newValue);
  }
  return ref.set(newValue);
}

export function onValue(key, callback) {
  const unsubscribe = firebase.database()
    .ref()
    .child(key)
    .on('value', (snapshot) => {
      callback(snapshot?.val());
    });
  return unsubscribe;
}
export function changeValue(key, newValue, type = ChangeMode.SET) {
  const ref = firebase.database().ref(state.gameId).child(key);
  if(type === ChangeMode.UPDATE) {
    return ref.update(newValue);
  }
  return ref.set(newValue);
}

export function onValue(key, callback) {
  const unsubscribe = firebase.database()
    .ref(state.gameId)
    .child(key)
    .on('value', (snapshot) => {
      callback(snapshot?.val());
    });
  return unsubscribe;
}

3

5

2

2

3

4

0

1

0

2

0

4

Just do it.

You're awesome!

QUESTIONS?

@liadyosef