• Announcements

    • khawk

      Download the Game Design and Indie Game Marketing Freebook   07/19/17

      GameDev.net and CRC Press have teamed up to bring a free ebook of content curated from top titles published by CRC Press. The freebook, Practices of Game Design & Indie Game Marketing, includes chapters from The Art of Game Design: A Book of Lenses, A Practical Guide to Indie Game Marketing, and An Architectural Approach to Level Design. The GameDev.net FreeBook is relevant to game designers, developers, and those interested in learning more about the challenges in game development. We know game development can be a tough discipline and business, so we picked several chapters from CRC Press titles that we thought would be of interest to you, the GameDev.net audience, in your journey to design, develop, and market your next game. The free ebook is available through CRC Press by clicking here. The Curated Books The Art of Game Design: A Book of Lenses, Second Edition, by Jesse Schell Presents 100+ sets of questions, or different lenses, for viewing a game’s design, encompassing diverse fields such as psychology, architecture, music, film, software engineering, theme park design, mathematics, anthropology, and more. Written by one of the world's top game designers, this book describes the deepest and most fundamental principles of game design, demonstrating how tactics used in board, card, and athletic games also work in video games. It provides practical instruction on creating world-class games that will be played again and again. View it here. A Practical Guide to Indie Game Marketing, by Joel Dreskin Marketing is an essential but too frequently overlooked or minimized component of the release plan for indie games. A Practical Guide to Indie Game Marketing provides you with the tools needed to build visibility and sell your indie games. With special focus on those developers with small budgets and limited staff and resources, this book is packed with tangible recommendations and techniques that you can put to use immediately. As a seasoned professional of the indie game arena, author Joel Dreskin gives you insight into practical, real-world experiences of marketing numerous successful games and also provides stories of the failures. View it here. An Architectural Approach to Level Design This is one of the first books to integrate architectural and spatial design theory with the field of level design. The book presents architectural techniques and theories for level designers to use in their own work. It connects architecture and level design in different ways that address the practical elements of how designers construct space and the experiential elements of how and why humans interact with this space. Throughout the text, readers learn skills for spatial layout, evoking emotion through gamespaces, and creating better levels through architectural theory. View it here. Learn more and download the ebook by clicking here. Did you know? GameDev.net and CRC Press also recently teamed up to bring GDNet+ Members up to a 20% discount on all CRC Press books. Learn more about this and other benefits here.
Sign in to follow this  
Followers 0
BurdenJohn

Java - UDP Datagram sending on main thread crashes game

9 posts in this topic

I'm trying to make a simple network game using a client and server.

Currently I have two threads running for both my client and server: - The Main thread which calculates player positions, physics, draws things to the screen, etc. - The Client/Server thread which handles sending/receiving of datagrams from/to the server or from/to the client

The reason I have two threads for each is because the receive() method call is BLOCKING, so I don't want it to block any of my gameplay while its waiting for a new datagram.

This works absolutely fine until I get to the point where I want to be able to send a datagram in my main thread.

EXAMPLE 
In the client, in my main thread, I have a keypress event where when my client presses the 'z' key, it sends a datagram to the server as a test. However, the program just completely stalls, no exceptions. It stalls to the point where I need to close it via task manager in Windows.

The socket I'm using to send to is declared and initialized in my ClientThread class (which extends Thread). It gets initialized when run() is called in my ClientThread:

      public void run() {
          try {
              // Construct the socket
              socket = new DatagramSocket();
              ...

I can easily send datagrams in my ClientThread/ServerThread, but when I try to send them in my MainThread at any given time (e.g. when a client presses Z and wants to send a test message), the program stalls/crashes.

Why is it crashing and what can I do to fix this?
Is it okay to use one thread for receiving/sending, and another thread for doing other calculations but also including sending datagrams at any given time? (e.g. when a player presses the 'z' key or something else).

 

I have a feeling it has something to do with the fact that the DatagramSocket is declared and initialized inside the ClientThread/ServerThread class

and the MainThread is trying to gain access to it at any given time it wants (again, when a player presses the 'z' key for example).

Edited by BurdenJohn
0

Share this post


Link to post
Share on other sites

These are very complicated questions.  Make sure you aren't trying something too complicated.

 

For the UDP server, you should use a NonBlocking selector so that a single thread can handle all the send and receive messages.  Here is an example I just googled:

 

http://www.studytrails.com/java-io/non-blocking-io-multiplexing.jsp  

 

What you're looking for is an "NIO (New IO) Java Server."  This is really hard to get right.  I wrote a small UDP server using this years ago, and it was two years of debugging before it was stable.  

 

I would also suggest (without knowing what you're doing this may not be correct but is at least the right direction) that you use a BlockingQueue as a way to pass messages to waiting threads.  http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/BlockingQueue.html

 

Good Luck!

0

Share this post


Link to post
Share on other sites


Is it okay to use one thread for receiving/sending, and another thread for doing other calculations but also including sending datagrams at any given time?
Nope. Responsibilities man! If you already got one part of the program responsible for sending data, let it be THE place to send data.

 

You can use queues like Glass_Knife suggested for passing the tasks around. Game thread could send a task to the network thread, and that one will send the data when it can. That way you can organize how you send the data in one place (which is pretty important in networking code AFAIK).

 

But all of this goes beyond your current actual problem (ie, thread blocking).

1

Share this post


Link to post
Share on other sites


But all of this goes beyond your current actual problem (ie, thread blocking).

 

Yes, I may have jumped the gun with ideas.  I've been down this road before.  Having a single thread to send/recv UDP with the selectors, and then pass message to a blocking queue so the other end (thread, game loop, something) can pull the messages out when there's time.  It can also use the queue to send messages.

 

But you're right, the problem is that the slow server code can't live in the game loop because it may block if there are network issues.

0

Share this post


Link to post
Share on other sites

All right so majority here says I definitely should not be using my MainThread to send datagrams out using the DatagramSocket specified in my NetworkThread (ClientThread/ServerThread).

 

Instead I should use my MainThread to send tasks/events to my NetworkThread which will then execute what I want via the network.

 

Example:

A player presses the 'z' key to send a test message to the server.

Instead of the main thread directly sending the datagram, the mainthread sends a task to the networkthread

and based on the task I specified, it will send the corresponding message accordingly.

 

So lets say I manage to get tasks working. I'm still not really sure how I'm going to combat not being able to send/receive datagrams in the same NetworkThread issue.

 

I read through the links you posted and I'm not sure how I would implement them in my current project which uses two threads at the moment:

The main thread

A Network thread

 

Currently using two threads. Should I continue to use threads?

Should I possibly have an additional thread:

The main thread

A Network thread for SENDING

A Network thread for RECEIVING

 

If so, would the two threads be allowed to share the same DatagramSocket?

Or would they each have their own DatagramSocket instance, with the same host IP address and host port number?

 

Or would it be better to use this ServerSocketChannel you linked here:
http://www.studytrails.com/java-io/non-blocking-io-multiplexing.jsp

I've never used that before and I'm not sure how to implement it in my example. Would I have to completely throw away the DatagramSocket?

Or would I still be using it?
 

Sorry I'm just a little confused on how I would implement these solutions in my project.

 

EDIT:

I also found this:
http://www.eecs.harvard.edu/~mdw/proj/java-nbio/javadoc/seda/nbio/NonblockingDatagramSocket.html
Would it be better just to have one thread for everything if I'm able to successfuly implement this?

That way because its nonblocking, I would be able to:

- Compute game physics/logic in main thread

- Send datagrams in main thread

- Receive datagrams in main thread

 

Thoughts?

Edited by BurdenJohn
0

Share this post


Link to post
Share on other sites

Sorry I'm just a little confused on how I would implement these solutions in my project.

 

I think that's my fault.  Linking you to the NIO server isn't good if you've never done anything like that before.  How much time do you have to get this working?  If you have time and are really trying to understand how to make a networked game, then you need to understand the two principles seperatly before trying to put them together.

 

One type of application used for learning/studying/understanding networked applications is a simple chat server.  If you can get that working, you're well on your way.  A lot of the same problems you'll encounter in a game will come up with a chat server.

 

1.  Coding a server to listen for multiple clients.

2.  What does the server do when clients disconnect?

3.  How does a client connect to the server?

4.  How does the client send chat messages?

5.  How does the client receive message from other clients?

6.  How does the server communicate who's in the chat room?

7.  What do clients do if the server disconnects?

 

The great thing about a chat room is that the GUI isn't hard, and the messages are just text, and it seems really easy.  But try one, and then setup a server, and send it to a few friends, and see what happens.

Edited by Glass_Knife
0

Share this post


Link to post
Share on other sites

 


Sorry I'm just a little confused on how I would implement these solutions in my project.

 

I think that's my fault.  Linking you to the NIO server isn't good if you've never done anything like that before.  How much time do you have to get this working?  If you have time and are really trying to understand how to make a networked game, then you need to understand the two principles seperatly before trying to put them together.

 

One type of application used for learning/studying/understanding networked applications is a simple chat server.  If you can get that working, you're well on your way.  A lot of the same problems you'll encounter in a game will come up with a chat server.

 

1.  Coding a server to listen for multiple clients.

2.  What does the server do when clients disconnect?

3.  How does a client connect to the server?

4.  How does the client send chat messages?

5.  How does the client receive message from other clients?

6.  How does the server communicate who's in the chat room?

7.  What do clients do if the server disconnects?

 

The great thing about a chat room is that the GUI isn't hard, and the messages are just text, and it seems really easy.  But try one, and then setup a server, and send it to a few friends, and see what happens.

 

Well what I've currently got right now is basically I'm trying to be able to have functions like so when it comes to sending/receiving messages:

clearbuffer();

writebyte(1); //Message ID

writestring("John");

writeint(5);

send(datagramSocket);

 

On the other end:
 

switch(readbyte()){
    case 1:

    String name = readstring();

    int x = readint();

}

 

And so far this is working out great.

However I can only do this inside the NetworkThread.

 

Even for a simple chat system, I would want to be able to press a button "Send" that sends text from the GUI JTextField.

But the button press event for the "Send" button is triggered inside the Main Thread. In this case, as what's happening currently, the game will halt.

 

Should I just have three threads?

Main Thread

NetworkSendThread

NetworkReceiveThread

 

Or should I just use one thread:

Main Thread

and instead of using a DatagramSocket which is blocking, I would use this socket:

http://www.eecs.harvard.edu/~mdw/proj/java-nbio/javadoc/seda/nbio/NonblockingDatagramSocket.html

 

A huge advantage in terms of simplicity is that I can have my main game loop, and network loop all in one thread.

 

Right now I have two problems:

- How would I get my Main Thread to communicate with my NetworkThread,

e.g. When the player presses the 'z' key, somehow trigger an event in the NetworkThread telling that I've done so, and then the NetworkThread

would take care of sending the data through the network using its DatagramSocket.

 

- How on earth do I do sending/receiving on two separate threads? Would both of those threads need to somehow share the DatagramSocket?

Edited by BurdenJohn
0

Share this post


Link to post
Share on other sites


Even for a simple chat system, I would want to be able to press a button "Send" that sends text from the GUI JTextField.
But the button press event for the "Send" button is triggered inside the Main Thread. In this case, as what's happening currently, the game will halt.

 

This is what the BlockingQueue can solve.  The thread that generates the message, the thread that you don't want to block, creates a message and gives it to the blocking queue, then that thread continues on and never blocks.  Inside a different thread, the queue.get() method blocks until there is something in the queue.  In this case, the message.  That thread sends the message with the Datagram and the calls queue.get() again, going to sleep until there is another message.

 

The NIO server I linked allows you to send and receive from the same thread.  You could combine the NIO server with the queue, but now it is getting complicated.  This is how the server I wrote behaved.  The selector.select() method blocks until there is something to handle.  This method wakes up when there is something to handle with the UDP socket, such as a connect, or data to read, or data to write.  

 

If you want to be simple, have the UDP send a game state message and then receive a game state message.  Honestly I'm not sure the best way to do this.

0

Share this post


Link to post
Share on other sites

Hmm well thanks for all the help. I will try to use the BlockingQueue you suggested.

In any case if anyone wants to take a stab at it, here's my implementation so far:

 

ClientThread.java:
package Network;
 
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.logging.Level;
import java.util.logging.Logger;
 
import java.net.*;
import networkresearch.NetworkResearch;
import utils.ByteHandle;
import Network.NetworkBuffer;
import networkresearch.Player;
 
public class ClientThread extends Thread {
 
    public DatagramSocket socket;
    public DatagramPacket packet;
    public NetworkBuffer buffer;
    private NetworkResearch ui;
    private final static int PACKETSIZE = 100;
    InetAddress host;
    int serverport;
 
    //byte[] dataBuffer;
    public ClientThread(NetworkResearch observer) throws UnknownHostException {
        this.ui = observer;
        socket = null;
        host = InetAddress.getByName("127.0.0.1");
        serverport = Integer.parseInt("1024");
        buffer = new NetworkBuffer();
    }
 
    /*
     * Sends buffer to server
     */
    @Override
    public void run() {
        try {
            // Construct the socket
            socket = new DatagramSocket();
            buffer.clearBuffer();
            Integer pid = 0;
            ui.console.println("BYTE: " + pid.byteValue());
            NetworkUtils.writeByte(pid.byteValue(), buffer);
            packet = new DatagramPacket(buffer.getData(), buffer.getData().length, host, serverport);
            socket.send(packet);
 
            // Set a receive timeout, 2000 milliseconds
            //socket.setSoTimeout(2000);
            // Prepare the packet for receive
            while (true) {
                packet.setData(new byte[PACKETSIZE]);
 
                // Wait for a response from the server
                socket.receive(packet);
                buffer.clearBuffer(); // Clear the buffer of any previous data it had
                buffer.setData(packet.getData());
                // Message ID
                Byte b = NetworkUtils.readByte(buffer);
                int mid = b.intValue();
                ui.console.println("CLIENT MESID: " + mid);
                switch (mid) {
                    case 0:
                        ui.console.println("Server says welcome!");
 
                        buffer.clearBuffer();
                        NetworkUtils.writeByte(new Integer(1).byteValue(), buffer);
                        DatagramPacket sendPacket = new DatagramPacket(buffer.getData(), buffer.getData().length, packet.getAddress(), packet.getPort());
                        socket.send(sendPacket);
                        break;
                }
            }
 
 
        } catch (Exception e) {
            ui.console.println(e.toString());
            ui.state = "menu";
            ui.setPane(ui.mainPanel);
        } finally {
            if (socket != null) {
                socket.close();
            }
        }
    }
 
    public void stopClient() {
        System.out.println("STOPPING CLIENT");
        socket.close();
        this.stop();
    }
}
 
 
ServerThread.java:
package Network;
 
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JOptionPane;
import networkresearch.NetworkResearch;
import networkresearch.Player;
import utils.ByteHandle;
 
public class ServerThread extends Thread {
 
    private final static int PACKETSIZE = 100;
    private NetworkResearch ui;
    //DatagramPacket packet;
    DatagramSocket socket;
    NetworkBuffer buffer;
 
    public ServerThread(NetworkResearch observer) {
        this.ui = observer;
    }
 
    @Override
    public void run() {
        try {
            buffer = new NetworkBuffer();
            int port = Integer.parseInt("1024");
            socket = new DatagramSocket(port);
 
            ui.console.println("SERVER STARTED ON UDP PORT: " + port);
 
 
            while (true) {
                // Create a packet
                DatagramPacket packet = new DatagramPacket(new byte[PACKETSIZE], PACKETSIZE);
                // Receive a packet (blocking)
                socket.receive(packet);
                buffer.clearBuffer(); // Clear the buffer of any previous data it had
                buffer.setData(packet.getData());
                // Message ID
                Byte b = NetworkUtils.readByte(buffer);
                int mid = b.intValue();
                ui.console.println("SERVER MESID: " + mid);
                switch (mid) {
                    //Initial connection packet
                    case 0:
                        Player player = new Player(ui.gamePanel, "goblin");
                        ui.gamePanel.players.add(player);
 
                        buffer.clearBuffer();
                        NetworkUtils.writeByte(new Integer(0).byteValue(), buffer);
                        DatagramPacket sendPacket = new DatagramPacket(buffer.getData(), buffer.getData().length, packet.getAddress(), packet.getPort());
                        socket.send(sendPacket);
                        break;
 
                    case 1:
                        ui.console.println("Client sends a response back!");
                        break;
                }
                /*
                 byte[] newData = "Hello Client".getBytes();
                 DatagramPacket newPacket = new DatagramPacket(newData, newData.length, packet.getAddress(), packet.getPort());
                 // Return the packet to the sender
                 socket.send(newPacket);
                 */
            }
        } catch (Exception e) {
            System.out.println(e);
        }
    }
 
    public void stopServer() {
        System.out.println("STOPPING SERVER");
        socket.close();
        this.stop();
    }
}
 
GamePanel.java:
package networkresearch;
 
import java.awt.Graphics;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.JTextField;
 
import Network.ClientThread;
import Network.NetworkUtils;
import Network.ServerThread;
import java.awt.Dimension;
import java.net.UnknownHostException;
import javax.swing.JFrame;
 
public class GamePanel extends CorePanel {
 
    NetworkResearch ui;
    GameThread gameThread;
    ClientThread clientThread;
    ServerThread serverThread;
    BufferedImage[] images_players;
    public ArrayList<Player> players;
    BufferedImage gameBackground;
    long startTime = 0;
    long endTime = 0;
 
    GamePanel(NetworkResearch ui) throws IOException {
        images_players = new BufferedImage[5];
        images_players[0] = ui.loadAsset("player_knight.png");
        images_players[1] = ui.loadAsset("player_goblin.png");
        images_players[2] = ui.loadAsset("player_ogre.png");
        images_players[3] = ui.loadAsset("player_skeleton.png");
        images_players[4] = ui.loadAsset("player_wolf.png");
        this.ui = ui;
        JTextField nameField = new JTextField("Test Player");
        nameField.setColumns(6);
        //add(nameField);
 
        gameBackground = ui.loadAsset("gameBackground.png");
        players = new ArrayList();
    }
 
    @Override
    public void keyTyped(KeyEvent e) {
    }
 
    @Override
    public void keyPressed(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_BACK_SPACE) {
            goBackToMenu();
        }
 
        if (e.getKeyChar() == 'z') {
            ui.console.println("SENDING FROM CLIENT TO SERVER!");
            /**
             * THIS IS WHERE THE GAME WILL CRASH! ATTEMPTING TO SEND A DATAGRAM
             * OUTSIDE OF THE ClientThread CLASS!
             */
            try {
                int x = 5, y = 10;
                clientThread.buffer.clearBuffer();
                NetworkUtils.writeInt(x, clientThread.buffer);
                // Send it
                clientThread.socket.send(clientThread.packet);
            } catch (IOException ex) {
                Logger.getLogger(GamePanel.class.getName()).log(Level.SEVERE, null, ex);
            }
 
        }
 
        for (Player player : players) {
            switch (e.getKeyChar()) {
                case 'a':
                    player.keyLeft = true;
                    break;
                case 's':
                    player.keyDown = true;
                    break;
                case 'd':
                    player.keyRight = true;
                    break;
                case 'w':
                    player.keyUp = true;
                    break;
                case ' ':
                    player.keySpace = true;
                    break;
            }
        }
    }
 
    @Override
    public void keyReleased(KeyEvent e) {
        for (Player player : players) {
            switch (e.getKeyChar()) {
                case 'a':
                    player.keyLeft = false;
                    break;
                case 's':
                    player.keyDown = false;
                    break;
                case 'd':
                    player.keyRight = false;
                    break;
                case 'w':
                    player.keyUp = false;
                    break;
                case ' ':
                    player.keySpace = false;
                    break;
            }
        }
    }
 
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        g.drawImage(gameBackground, 0, 0, this);
        for (Iterator<Player> it = players.iterator(); it.hasNext();) {
            it.next().paint(g);
        }
    }
 
    @Override
    public void start() {
        gameThread = new GameThread(this);
        try {
            clientThread = new ClientThread(ui);
        } catch (UnknownHostException ex) {
            Logger.getLogger(GamePanel.class.getName()).log(Level.SEVERE, null, ex);
        }
        serverThread = new ServerThread(ui);
        this.setFocusable(true);
        gameThread.start();
        if (ui.state.equals("inserver")) {
            ui.setTitle("SERVER");
            serverThread.start();
            ui.console.setTitle("Console: Server");
        } else if (ui.state.equals("inclient")) {
 
            Player player = new Player(this, "goblin");
            players.add(player);
 
            ui.setTitle("CLIENT");
            clientThread.start();
            ui.console.setTitle("Console: Client");
        }
        startTime = new Date().getTime();
    }
 
    public void update() {
        for (Iterator<Player> it = players.iterator(); it.hasNext();) {
            it.next().update();
        }
    }
 
    public void goBackToMenu() {
        System.out.println("GOING BACK TO MENU");
        ui.state = "menu";
        ui.setPane(ui.mainPanel);
        ui.curPanel.repaint();
    }
 
    @Override
    public void stop() {
        if (gameThread.isAlive()) {
            gameThread.stop();
        }
        if (clientThread.isAlive()) {
            clientThread.stopClient();
        } else if (serverThread.isAlive()) {
            serverThread.stopServer();
        }
    }
}
 
The game stalls/crashes in GamePanel.java at the try/catch block (its commented).
0

Share this post


Link to post
Share on other sites

I see two problems.  One is with the way you're trying to use the client.  The other is a misunderstanding of the UDP protocol.

 

First, the way you are using the client may seem correct, and at first, I wasn't sure what was wrong.  The problem is this part:

        if (e.getKeyChar() == 'z') {
            ui.console.println("SENDING FROM CLIENT TO SERVER!");
            /**
             * THIS IS WHERE THE GAME WILL CRASH! ATTEMPTING TO SEND A DATAGRAM
             * OUTSIDE OF THE ClientThread CLASS!
             */
            try {
                int x = 5, y = 10;
                clientThread.buffer.clearBuffer();
                NetworkUtils.writeInt(x, clientThread.buffer);
                // Send it
                clientThread.socket.send(clientThread.packet);
            } catch (IOException ex) {
                Logger.getLogger(GamePanel.class.getName()).log(Level.SEVERE, null, ex);
            }
 
        }

The problem is subtle.  If you don't use the NIO kind of socket, then the socket can only do one thing at a time.  I made a quick test to verify this.  At least on my machine, if you do a socket.receive(), and while you're waiting for a response, you call socket.send() from a different thread, then the sent message will not go until after the socket.receive() returns.

 

The server starts up, then the client connects, sends the first message, and the server responds.  But then the client waits for a second message, before anything else has been sent to the server.  So this is one problem.  Now when you say that the game locks up or crashes, that is confusing, because the socket.send() method doesn't block until the message is sent, so I suspect something else is wrong too.

 

But this is just a symptom of the problem, not that actual problem.  The real problem is that UDP is an unreliable connectionless network protocol.  Think of it like writing a message on a piece of paper, wading it up, and throwing it over a fence.  There is no guarantee that anyone got the message.  Setting the client up to send a message and wait for a response doesn't work, because you can't assume that anything will ever come back.  Imagine if you started the Client before the server, or the server was restarted in the middle of communication.  The client.send() message would just go away, never delivered to anyone.  Then no one would ever send a message back.

 

If you want that kind of send/receive message passing, you need to use TCP/IP where you get back responses and know when the connection is gone.  If you're going to use UDP you either need a NIO socket that can send and receive at the same time, or you need two different sockets.  One to send, and one to receive.  And yes, this makes things way more complicated.

 

This is why I suggested a chat server.  A lot of these things will come up.

Edited by Glass_Knife
0

Share this post


Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!


Register a new account

Sign in

Already have an account? Sign in here.


Sign In Now
Sign in to follow this  
Followers 0