diff --git a/Cargo.lock b/Cargo.lock
index 0ce851c..d1ef4d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -142,6 +142,17 @@ dependencies = [
  "pin-utils",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "hermit-abi"
 version = "0.1.19"
@@ -164,6 +175,7 @@ dependencies = [
  "bytes",
  "futures",
  "minecraft-protocol",
+ "rand 0.8.4",
  "tokio",
 ]
 
@@ -291,6 +303,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba"
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.32"
@@ -317,9 +335,9 @@ checksum = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca"
 dependencies = [
  "autocfg 0.1.7",
  "libc",
- "rand_chacha",
+ "rand_chacha 0.1.1",
  "rand_core 0.4.2",
- "rand_hc",
+ "rand_hc 0.1.0",
  "rand_isaac",
  "rand_jitter",
  "rand_os",
@@ -328,6 +346,18 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "rand"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.3",
+ "rand_hc 0.3.1",
+]
+
 [[package]]
 name = "rand_chacha"
 version = "0.1.1"
@@ -338,6 +368,16 @@ dependencies = [
  "rand_core 0.3.1",
 ]
 
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.3",
+]
+
 [[package]]
 name = "rand_core"
 version = "0.3.1"
@@ -353,6 +393,15 @@ version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
 
+[[package]]
+name = "rand_core"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
+dependencies = [
+ "getrandom",
+]
+
 [[package]]
 name = "rand_hc"
 version = "0.1.0"
@@ -362,6 +411,15 @@ dependencies = [
  "rand_core 0.3.1",
 ]
 
+[[package]]
+name = "rand_hc"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7"
+dependencies = [
+ "rand_core 0.6.3",
+]
+
 [[package]]
 name = "rand_isaac"
 version = "0.1.1"
@@ -512,10 +570,16 @@ version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "90dbc611eb48397705a6b0f6e917da23ae517e4d127123d2cf7674206627d32a"
 dependencies = [
- "rand",
+ "rand 0.6.5",
  "serde",
 ]
 
+[[package]]
+name = "wasi"
+version = "0.10.2+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
diff --git a/Cargo.toml b/Cargo.toml
index d17e7b7..2ffac5d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,4 +22,5 @@ edition = "2021"
 bytes = "1.1"
 futures = { version = "0.3", default-features = false }
 minecraft-protocol = { git = "https://github.com/timvisee/minecraft-protocol", rev = "c578492" }
-tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "macros", "sync"] }
+rand = "0.8"
+tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "macros", "sync", "time"] }
diff --git a/README.md b/README.md
index 66e0f82..e8491ba 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # lazymc
 
-`lazymc` puts your Minecraft server at rest when idle, and wakes it up when
+`lazymc` puts your Minecraft server to rest when idle, and wakes it up when
 players connect.
 
 ## License
diff --git a/src/main.rs b/src/main.rs
index 4e05fab..935541f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,5 @@
 pub mod config;
+pub mod monitor;
 pub mod protocol;
 pub mod types;
 
@@ -25,13 +26,19 @@ use protocol::{Client, ClientState, RawPacket};
 async fn main() -> Result<(), ()> {
     println!(
         "Proxying public {} to internal {}",
-        ADDRESS_PUBLIC, ADDRESS_PROXY
+        ADDRESS_PUBLIC, ADDRESS_PROXY,
     );
 
+    let server_state = monitor::ServerState::default().shared();
+
     // Listen for new connections
     // TODO: do not drop error here
     let listener = TcpListener::bind(ADDRESS_PUBLIC).await.map_err(|_| ())?;
 
+    // Spawn server monitor
+    let addr = ADDRESS_PROXY.parse().expect("invalid server IP");
+    tokio::spawn(monitor::monitor_server(addr, server_state));
+
     // Proxy all incomming connections
     while let Ok((inbound, _)) = listener.accept().await {
         let client = Client::default();
@@ -53,7 +60,7 @@ async fn main() -> Result<(), ()> {
 }
 
 /// Read raw packet from stream.
-async fn read_packet<'a>(
+pub async fn read_packet<'a>(
     buf: &mut BytesMut,
     stream: &mut ReadHalf<'a>,
 ) -> Result<Option<(RawPacket, Vec<u8>)>, ()> {
@@ -113,7 +120,7 @@ async fn proxy(client: Client, mut inbound: TcpStream, addr_target: String) -> R
 
     let (client_send_queue, mut client_to_send) = unbounded_channel::<Vec<u8>>();
 
-    let server_available = false;
+    let server_available = true;
 
     let client_to_server = async {
         // Incoming buffer
diff --git a/src/monitor.rs b/src/monitor.rs
new file mode 100644
index 0000000..f21508f
--- /dev/null
+++ b/src/monitor.rs
@@ -0,0 +1,179 @@
+// TODO: remove all unwraps/expects here!
+
+use std::net::SocketAddr;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use bytes::BytesMut;
+use minecraft_protocol::decoder::Decoder;
+use minecraft_protocol::encoder::Encoder;
+use minecraft_protocol::version::v1_14_4::handshake::Handshake;
+use minecraft_protocol::version::v1_14_4::status::{PingRequest, PingResponse};
+use rand::Rng;
+use tokio::io::AsyncWriteExt;
+use tokio::net::TcpStream;
+
+use crate::protocol::{self, ClientState, RawPacket};
+
+/// Minecraft protocol version used when polling server status.
+const PROTOCOL_VERSION: i32 = 754;
+
+/// Monitor ping inverval in seconds.
+const MONITOR_PING_INTERVAL: u64 = 2;
+
+/// Ping timeout in seconds.
+const PING_TIMEOUT: u64 = 8;
+
+/// Shared server state.
+#[derive(Default, Debug)]
+pub struct ServerState {
+    /// Whether the server is online.
+    online: AtomicBool,
+
+    /// Whether the server is starting.
+    starting: AtomicBool,
+}
+
+impl ServerState {
+    /// Transform into shared instance.
+    pub fn shared(self) -> Arc<Self> {
+        Arc::new(self)
+    }
+
+    /// Whether the server is online.
+    pub fn online(&self) -> bool {
+        self.online.load(Ordering::Relaxed)
+    }
+
+    /// Set whether the server is online.
+    pub fn set_online(&self, online: bool) {
+        self.online.store(online, Ordering::Relaxed)
+    }
+
+    /// Whether the server is starting.
+    pub fn starting(&self) -> bool {
+        self.starting.load(Ordering::Relaxed)
+    }
+
+    /// Set whether the server is starting.
+    pub fn set_starting(&self, starting: bool) {
+        self.starting.store(starting, Ordering::Relaxed)
+    }
+}
+
+/// Poll server state.
+///
+/// Returns `true` if a ping succeeded.
+pub async fn poll_server(addr: SocketAddr) -> bool {
+    attempt_connect(addr).await.is_ok()
+}
+
+/// Monitor server.
+pub async fn monitor_server(addr: SocketAddr, state: Arc<ServerState>) {
+    loop {
+        eprint!("Polling {}: ", addr);
+        let online = poll_server(addr).await;
+
+        state.set_online(online);
+
+        tokio::time::sleep(Duration::from_secs(MONITOR_PING_INTERVAL)).await;
+    }
+}
+
+/// Attemp to connect to the given server.
+async fn attempt_connect(addr: SocketAddr) -> Result<(), ()> {
+    let mut stream = TcpStream::connect(addr).await.map_err(|_| ())?;
+
+    // Send handshake
+    send_handshake(&mut stream, addr).await?;
+
+    // Send ping request
+    let token = send_ping(&mut stream).await?;
+
+    // Wait for ping with timeout
+    wait_for_ping_timeout(&mut stream, token).await?;
+
+    Ok(())
+}
+
+/// Send handshake.
+async fn send_handshake(stream: &mut TcpStream, addr: SocketAddr) -> Result<(), ()> {
+    let handshake = Handshake {
+        protocol_version: PROTOCOL_VERSION,
+        server_addr: addr.ip().to_string(),
+        server_port: addr.port(),
+        next_state: ClientState::Status.to_id(),
+    };
+
+    let mut packet = Vec::new();
+    handshake.encode(&mut packet).map_err(|_| ())?;
+
+    let raw = RawPacket::new(protocol::HANDSHAKE_PACKET_ID_HANDSHAKE, packet)
+        .encode()
+        .map_err(|_| ())?;
+
+    stream.write_all(&raw).await.map_err(|_| ())?;
+
+    Ok(())
+}
+
+/// Send ping requets.
+///
+/// Returns sent ping time token on success.
+async fn send_ping(stream: &mut TcpStream) -> Result<u64, ()> {
+    // Generate a random ping token
+    let token = rand::thread_rng().gen();
+
+    let ping = PingRequest { time: token };
+
+    let mut packet = Vec::new();
+    ping.encode(&mut packet).map_err(|_| ())?;
+
+    let raw = RawPacket::new(protocol::STATUS_PACKET_ID_PING, packet)
+        .encode()
+        .map_err(|_| ())?;
+
+    stream.write_all(&raw).await.map_err(|_| ())?;
+
+    Ok(token)
+}
+
+/// Wait for a ping response.
+async fn wait_for_ping(stream: &mut TcpStream, token: u64) -> Result<(), ()> {
+    // Get stream reader, set up buffer
+    let (mut reader, mut _writer) = stream.split();
+    let mut buf = BytesMut::new();
+
+    loop {
+        // Read packet from stream
+        let (packet, _raw) = match crate::read_packet(&mut buf, &mut reader).await {
+            Ok(Some(packet)) => packet,
+            Ok(None) => break,
+            Err(_) => continue,
+        };
+
+        // Catch ping response
+        if packet.id == protocol::STATUS_PACKET_ID_PING {
+            let ping = PingResponse::decode(&mut packet.data.as_slice()).map_err(|_| ())?;
+
+            // Ensure ping token is correct
+            if ping.time != token {
+                break;
+            }
+
+            return Ok(());
+        }
+    }
+
+    // Some error occurred
+    Err(())
+}
+
+/// Wait for a ping response with timeout.
+async fn wait_for_ping_timeout(stream: &mut TcpStream, token: u64) -> Result<(), ()> {
+    let ping = wait_for_ping(stream, token);
+    tokio::time::timeout(Duration::from_secs(PING_TIMEOUT), ping)
+        .await
+        .map_err(|_| ())?
+}
diff --git a/src/protocol.rs b/src/protocol.rs
index 35d1770..f97f347 100644
--- a/src/protocol.rs
+++ b/src/protocol.rs
@@ -2,6 +2,7 @@ use std::sync::Mutex;
 
 use crate::types;
 
+pub const HANDSHAKE_PACKET_ID_HANDSHAKE: i32 = 0;
 pub const STATUS_PACKET_ID_STATUS: i32 = 0;
 pub const STATUS_PACKET_ID_PING: i32 = 1;
 pub const LOGIN_PACKET_ID_LOGIN_START: i32 = 0;