git » crow.game » commit 72a63c5

Add netty codec implementation and usage example.

author b0in
2023-11-27 00:22:25 UTC
committer b0in
2023-11-27 00:32:23 UTC
parent 916459367c641b6087afdf60a828d5fa781ab5e1

Add netty codec implementation and usage example.

 * pingpong example!
 * new devlog.
 * working netty codec implementation.
 * markdown support for spotless plugin

crow.game.codec.api/src/main/java/crow/game/codec/Context.java +10 -5
crow.game.codec.api/src/main/java/crow/game/codec/PacketRule.java +6 -6
crow.game.codec.api/src/main/java/crow/game/codec/package-info.java +2 -0
crow.game.codec.netty/pom.xml +29 -0
crow.game.codec.netty/src/main/java/crow/game/codec/netty/FrameCodec.java +122 -0
crow.game.codec.netty/src/main/java/crow/game/codec/netty/NettyWrappedContext.java +53 -0
crow.game.codec.netty/src/main/java/crow/game/codec/netty/PacketCodec.java +76 -0
crow.game.codec.netty/src/main/java/crow/game/codec/netty/package-info.java +5 -0
crow.game.examples.pingpong/pom.xml +28 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/MainClient.java +55 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/PingPongClientInitializer.java +68 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/package-info.java +4 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Constants.java +9 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Ping.java +6 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PingPacketHandler.java +28 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Pong.java +6 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PongPacketHandler.java +27 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/RootContext.java +65 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/package-info.java +16 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/MainServer.java +56 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/PingPongServerInitializer.java +78 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/package-info.java +4 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/Main.java +26 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/PrefixThreadFactory.java +30 -0
crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/package-info.java +4 -0
crow.game.site/src/site/markdown/devlog.md +3 -1
crow.game.site/src/site/markdown/devlog/2023-11-24.md +2 -1
crow.game.site/src/site/markdown/devlog/2023-11-26.md +70 -0
pom.xml +8 -0

diff --git a/crow.game.codec.api/src/main/java/crow/game/codec/Context.java b/crow.game.codec.api/src/main/java/crow/game/codec/Context.java
index f13e72f..14da0b3 100644
--- a/crow.game.codec.api/src/main/java/crow/game/codec/Context.java
+++ b/crow.game.codec.api/src/main/java/crow/game/codec/Context.java
@@ -1,5 +1,7 @@
 package crow.game.codec;
 
+import java.util.List;
+
 /**
  * Context wraps the calling context when performing packet logic.
  * <p>
@@ -11,30 +13,33 @@ public interface Context {
     /**
      * @return the current RO protocol version as an integer.
      */
-    public int supportedProtocolVersion();
+    int supportedProtocolVersion();
+
+    List<PacketHandler<?>> lookupPacketHandler(int prefix);
+    List<PacketHandler<?>> lookupPacketHandler(Class<?> clz);
 
     /**
      * @return the underlying object, usually a Netty connection object.
      */
-    public Object underlyingObject();
+    Object underlyingObject();
 
     /**
      * @return the location of the codec workflow this context was built in.
      */
-    public Source callingSource();
+    Source callingSource();
 
     /**
      * Source determines where the context was constructed and called into. Packet Rules
      * can be constructed in the INIT phase, the DECODE phase, or the ENCODE phase
      * and packet handlers may want to know which is being called for now.
      */
-    public static enum Source {
+    enum Source {
         INIT,
         DECODE,
         ENCODE,
     }
 
-    public default boolean matchesPacketVersion(PacketRule pr) {
+    default boolean matchesPacketVersion(PacketRule pr) {
         return (
             this.supportedProtocolVersion() >= pr.minVersion() &&
             this.supportedProtocolVersion() <= pr.maxVersion()
diff --git a/crow.game.codec.api/src/main/java/crow/game/codec/PacketRule.java b/crow.game.codec.api/src/main/java/crow/game/codec/PacketRule.java
index 522fefb..1273cdd 100644
--- a/crow.game.codec.api/src/main/java/crow/game/codec/PacketRule.java
+++ b/crow.game.codec.api/src/main/java/crow/game/codec/PacketRule.java
@@ -7,7 +7,7 @@ package crow.game.codec;
  *
  */
 public record PacketRule(
-    int minVersion, int maxVersion, int prefix, int data, Type type, String name, Class<?> clz
+    int minVersion, int maxVersion, short prefix, int data, Type type, String name, Class<?> clz
 ) {
     /**
      * The type of packet, currently only 2. see classdoc.
@@ -25,7 +25,7 @@ public record PacketRule(
      * @param type The type of the packet, STATIC or DYNAMIC
      * @param clz The class of the POJO we are serializing and deserializing.
      */
-    public PacketRule(int prefix, int data, Type type, Class<?> clz) {
+    public PacketRule(short prefix, int data, Type type, Class<?> clz) {
         this(-1, -1, prefix, data, type, clz.getSimpleName(), clz);
     }
 
@@ -39,7 +39,7 @@ public record PacketRule(
      * @param clz The class of the POJO we are serializing and deserializing.
      */
     public PacketRule(
-        int prefix,
+        short prefix,
         int data,
         Type type,
         String name,
@@ -62,7 +62,7 @@ public record PacketRule(
     public PacketRule(
         int minVersion,
         int maxVersion,
-        int prefix,
+        short prefix,
         int data,
         Type type,
         String name,
@@ -86,7 +86,7 @@ public record PacketRule(
      * @param clz The class that we are serializing and deserializing.
      * @return The packet rule object.
      */
-    public static PacketRule Dynamic(int prefix, int sizeTag, Class<?> clz) {
+    public static PacketRule Dynamic(short prefix, int sizeTag, Class<?> clz) {
         return new PacketRule(prefix, sizeTag, Type.DYNAMIC, clz);
     }
 
@@ -99,7 +99,7 @@ public record PacketRule(
      * @param clz The class that we are serializing and deserializing.
      * @return The packet rule object.
      */
-    public static PacketRule Static(int prefix, int size, Class<?> clz) {
+    public static PacketRule Static(short prefix, int size, Class<?> clz) {
         return new PacketRule(prefix, size, Type.STATIC, clz);
     }
 }
diff --git a/crow.game.codec.api/src/main/java/crow/game/codec/package-info.java b/crow.game.codec.api/src/main/java/crow/game/codec/package-info.java
index 60c394f..a7e5470 100644
--- a/crow.game.codec.api/src/main/java/crow/game/codec/package-info.java
+++ b/crow.game.codec.api/src/main/java/crow/game/codec/package-info.java
@@ -5,6 +5,8 @@
  * otherwise decoupled from Netty. sub-package contains the Netty-specific code
  * for building {@link crow.game.codec.Packet} objects.
  * <p>
+ * TODO: switch to {@link java.nio.ByteBuffer} which can remove the netty dependency.
+ * <p>
  * There are multiple hooks for handling differing packet versions in this API:
  * <p>
  * You can define your POJO as "MyPacket20NN0101" alongside {@link crow.game.codec.PacketHandler} "MyPacketHandler20NN0101"
diff --git a/crow.game.codec.netty/pom.xml b/crow.game.codec.netty/pom.xml
new file mode 100644
index 0000000..ec58861
--- /dev/null
+++ b/crow.game.codec.netty/pom.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+     xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>crow.game</groupId>
+    <artifactId>crow.game.root</artifactId>
+    <version>0.1-SNAPSHOT</version>
+  </parent>
+  <artifactId>crow.game.codec.netty</artifactId>
+  <dependencies>
+    <dependency>
+      <groupId>crow.game</groupId>
+      <artifactId>crow.game.codec.api</artifactId>
+      <version>0.1-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>io.netty</groupId>
+      <artifactId>netty-buffer</artifactId>
+      <version>4.1.101.Final</version>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+      <version>1.7.35</version>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/crow.game.codec.netty/src/main/java/crow/game/codec/netty/FrameCodec.java b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/FrameCodec.java
new file mode 100644
index 0000000..55befc9
--- /dev/null
+++ b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/FrameCodec.java
@@ -0,0 +1,122 @@
+package crow.game.codec.netty;
+
+import crow.game.codec.Context;
+import crow.game.codec.Packet;
+import crow.game.codec.PacketHandler;
+import crow.game.codec.PacketRule;
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.ByteToMessageCodec;
+import io.netty.handler.codec.UnsupportedMessageTypeException;
+import java.util.List;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The FrameCodec converts to and from {@link ByteBuf} streams into individual
+ * {@link crow.game.codec.Packet} objects. The first two bytes of a packet determine which type
+ * of packet it is (called the packet ID).
+ *
+ * @see crow.game.codec.PacketRule
+ * @see crow.game.codec.Packet
+ * @see PacketCodec
+ */
+public class FrameCodec extends ByteToMessageCodec<Packet> {
+
+    static Logger logger = LoggerFactory.getLogger(FrameCodec.class);
+
+    //
+    protected final Context context;
+
+    public FrameCodec(Context context) {
+        this.context = context;
+    }
+
+    protected boolean packetRuleHasMoreData(ByteBuf in, PacketRule pr) {
+        PacketRule.Type s = pr.type();
+        return switch (s) {
+            case DYNAMIC -> true;
+            case STATIC -> pr.data() <= in.readableBytes() + 2;
+        };
+    }
+
+    protected Optional<PacketRule> resolvePacketRule(
+        Context ctx,
+        int prefix,
+        ByteBuf in
+    ) {
+        return ctx
+            .lookupPacketHandler(prefix)
+            .stream() //
+            .map(((PacketHandler<?> ph) -> ph.rule(ctx)))
+            .filter((PacketRule pr) -> ctx.matchesPacketVersion(pr))
+            .filter((PacketRule pr) -> packetRuleHasMoreData(in, pr))
+            .findFirst();
+    }
+
+    @Override
+    protected void encode(ChannelHandlerContext ctx, Packet msg, ByteBuf out) {
+        PacketRule pr = msg.rule();
+
+        short prefix = (short) pr.prefix();
+        out.writeShortLE(prefix);
+        if (pr.type() == PacketRule.Type.DYNAMIC) {
+            if (pr.data() == 2) {
+                out.writeShortLE(msg.buffer().readableBytes() + 2);
+            } else {
+                out.writeIntLE(msg.buffer().readableBytes() + 4);
+            }
+        }
+
+        out.writeBytes(msg.buffer());
+    }
+
+    @Override
+    protected void decode(
+        ChannelHandlerContext ctx,
+        ByteBuf in,
+        List<Object> out
+    ) {
+        short x = in.getShortLE(in.readerIndex());
+        String readableX = String.format("0x%x", x);
+        Context wrapped = new NettyWrappedContext(
+            Context.Source.DECODE,
+            this.context,
+            ctx
+        );
+
+        Optional<PacketRule> rule = resolvePacketRule(wrapped, x, in);
+        if (rule.isEmpty()) {
+            logger.warn("no packet rule for {} - {}", readableX, x);
+            throw new UnsupportedMessageTypeException();
+        }
+
+        PacketRule packetRule = rule.get();
+
+        if (packetRule.type().equals(PacketRule.Type.STATIC)) {
+            out.add(
+                new Packet(
+                    in.readBytes(rule.get().data() + 2).copy(),
+                    rule.get()
+                )
+            );
+        } else {
+            int size = -1;
+            // all packets sizes are just the 2 or 4 byte words AFTER the packet id
+            if (packetRule.data() == 2) {
+                if (in.readableBytes() >= 2 + 2) {
+                    size = in.getShortLE(in.readerIndex() + 2);
+                }
+            } else {
+                if (in.readableBytes() > 2 + 4) {
+                    size = in.getIntLE(in.readerIndex() + 2);
+                }
+            }
+            if (in.readableBytes() < 2 + size) {
+                return;
+            }
+            out.add(new Packet(in.readBytes(size), rule.get()));
+        }
+    }
+}
diff --git a/crow.game.codec.netty/src/main/java/crow/game/codec/netty/NettyWrappedContext.java b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/NettyWrappedContext.java
new file mode 100644
index 0000000..09e1bb6
--- /dev/null
+++ b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/NettyWrappedContext.java
@@ -0,0 +1,53 @@
+package crow.game.codec.netty;
+
+import crow.game.codec.Context;
+import crow.game.codec.PacketHandler;
+import io.netty.channel.ChannelHandlerContext;
+import java.util.List;
+
+/**
+ * Implementation of {@link Context} which wraps an upper-level context and sets the
+ * {@link crow.game.codec.Context.Source} to the given value and sets the underlying object to an
+ * instance of {@link ChannelHandlerContext}.
+ */
+public class NettyWrappedContext implements Context {
+
+    protected final Context context;
+    protected final ChannelHandlerContext nettyContext;
+    protected final Context.Source source;
+
+    public NettyWrappedContext(
+        Context.Source src,
+        Context context,
+        ChannelHandlerContext nettyContext
+    ) {
+        this.context = context;
+        this.nettyContext = nettyContext;
+        this.source = src;
+    }
+
+    @Override
+    public int supportedProtocolVersion() {
+        return this.context.supportedProtocolVersion();
+    }
+
+    @Override
+    public List<PacketHandler<?>> lookupPacketHandler(Class<?> clz) {
+        return this.context.lookupPacketHandler(clz);
+    }
+
+    @Override
+    public List<PacketHandler<?>> lookupPacketHandler(int prefix) {
+        return this.context.lookupPacketHandler(prefix);
+    }
+
+    @Override
+    public Object underlyingObject() {
+        return this.nettyContext;
+    }
+
+    @Override
+    public Source callingSource() {
+        return this.source;
+    }
+}
diff --git a/crow.game.codec.netty/src/main/java/crow/game/codec/netty/PacketCodec.java b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/PacketCodec.java
new file mode 100644
index 0000000..3e9e912
--- /dev/null
+++ b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/PacketCodec.java
@@ -0,0 +1,76 @@
+package crow.game.codec.netty;
+
+import crow.game.codec.Context;
+import crow.game.codec.Packet;
+import crow.game.codec.PacketHandler;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.MessageToMessageCodec;
+import java.util.List;
+
+/**
+ * Codec responsible for converting to and from {@link crow.game.codec.Packet} objects and the
+ * business-logic specific POJOs.
+ */
+public class PacketCodec extends MessageToMessageCodec<Packet, Object> {
+
+    protected final Context context;
+
+    public PacketCodec(Context ctx) {
+        this.context = ctx;
+    }
+
+    @Override
+    protected void decode(
+        ChannelHandlerContext ctx,
+        Packet msg,
+        List<Object> out
+    ) throws Exception {
+        Context wrCtx = new NettyWrappedContext(
+            Context.Source.DECODE,
+            context,
+            ctx
+        );
+        PacketHandler<?> h =
+            (
+                wrCtx
+                    .lookupPacketHandler(msg.rule().prefix()) //
+                    .stream() //
+                    .filter(hx -> wrCtx.matchesPacketVersion(hx.rule(wrCtx))) //
+                    .findFirst() //
+                    .orElseThrow()
+            );
+
+        msg.buffer().readShortLE(); // drop the packet ID
+        out.add(h.fromPacket(wrCtx, msg.buffer()));
+    }
+
+    @Override
+    protected void encode(
+        ChannelHandlerContext ctx,
+        Object msg,
+        List<Object> out
+    ) throws Exception {
+        Context wrCtx = new NettyWrappedContext(
+            Context.Source.ENCODE,
+            this.context,
+            ctx
+        );
+
+        @SuppressWarnings("unchecked")
+        PacketHandler<Object> h = (PacketHandler<Object>) wrCtx
+            .lookupPacketHandler(msg.getClass())
+            .stream() //
+            .filter(hx -> wrCtx.matchesPacketVersion(hx.rule(wrCtx))) //
+            .findFirst() //
+            .orElseThrow();
+
+        // TODO: idk, pool this? use better buffer sizes??
+        // TODO: does this leak
+        ByteBuf directBuffer = Unpooled.directBuffer(1024);
+
+        h.toPacket(wrCtx, directBuffer, msg);
+        out.add(new Packet(directBuffer, h.rule(wrCtx)));
+    }
+}
diff --git a/crow.game.codec.netty/src/main/java/crow/game/codec/netty/package-info.java b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/package-info.java
new file mode 100644
index 0000000..92c9c67
--- /dev/null
+++ b/crow.game.codec.netty/src/main/java/crow/game/codec/netty/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * Package containing all the serialization and deserialization logic for the
+ * protocol.
+ */
+package crow.game.codec.netty;
diff --git a/crow.game.examples.pingpong/pom.xml b/crow.game.examples.pingpong/pom.xml
new file mode 100644
index 0000000..334750c
--- /dev/null
+++ b/crow.game.examples.pingpong/pom.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+     xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>crow.game</groupId>
+    <artifactId>crow.game.root</artifactId>
+    <version>0.1-SNAPSHOT</version>
+  </parent>
+  <artifactId>crow.game.examples.pingpong</artifactId>
+  <dependencies>
+    <dependency>
+      <groupId>crow.game</groupId>
+      <artifactId>crow.game.codec.netty</artifactId>
+      <version>0.1-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-api</artifactId>
+      <version>1.7.36</version>
+    </dependency>
+    <dependency>
+      <groupId>org.slf4j</groupId>
+      <artifactId>slf4j-simple</artifactId>
+      <version>1.7.36</version>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/MainClient.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/MainClient.java
new file mode 100644
index 0000000..7287ebc
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/MainClient.java
@@ -0,0 +1,55 @@
+package crow.game.examples.pingpong.client;
+
+import crow.game.codec.Context;
+import crow.game.examples.pingpong.proto.Constants;
+import crow.game.examples.pingpong.proto.RootContext;
+import crow.game.examples.pingpong.utils.PrefixThreadFactory;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioSocketChannel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This wraps the client into a runnable that can be run as a main entrypoint
+ * OR passed into a Thread.
+ *
+ * @see crow.game.examples.pingpong.utils.Main
+ */
+public class MainClient implements Runnable {
+
+    protected static final Logger logger = LoggerFactory.getLogger(
+        MainClient.class
+    );
+
+    public static void main(String[] args) {
+        new MainClient().run();
+    }
+
+    public void run() {
+        EventLoopGroup workerGroup = new NioEventLoopGroup(
+            new PrefixThreadFactory("client-worker")
+        );
+        Context context = new RootContext();
+        try {
+            Bootstrap b = new Bootstrap(); //
+
+            b
+                .group(workerGroup) //
+                .channel(NioSocketChannel.class) //
+                .handler(new PingPongClientInitializer(context));
+
+            ChannelFuture f = b
+                .connect("127.0.0.1", Constants.LISTEN_PORT)
+                .sync();
+            f.channel().closeFuture().sync();
+        } catch (InterruptedException e) {
+            //e.printStackTrace();
+        } finally {
+            workerGroup.shutdownGracefully();
+        }
+        logger.info("client finished");
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/PingPongClientInitializer.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/PingPongClientInitializer.java
new file mode 100644
index 0000000..cac4f26
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/PingPongClientInitializer.java
@@ -0,0 +1,68 @@
+package crow.game.examples.pingpong.client;
+
+import crow.game.codec.Context;
+import crow.game.codec.netty.FrameCodec;
+import crow.game.codec.netty.PacketCodec;
+import crow.game.examples.pingpong.proto.Ping;
+import crow.game.examples.pingpong.proto.Pong;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.socket.SocketChannel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Netty clients logic for the ping pong example. it registers
+ * the standard {@link FrameCodec} and {@link PacketCodec} then
+ * just sends a {@link Ping} message once the connection is established, finally
+ * drops the connection once a {@link Pong} is received.
+ */
+public class PingPongClientInitializer
+    extends ChannelInitializer<SocketChannel> {
+
+    public static final Logger logger = LoggerFactory.getLogger(
+        PingPongClientInitializer.class
+    );
+
+    protected final Context context;
+
+    public PingPongClientInitializer(Context ctx) {
+        this.context = ctx;
+    }
+
+    @Override
+    protected void initChannel(SocketChannel ch) throws Exception {
+        logger.info("initializing channel");
+        ch
+            .pipeline() //
+            .addLast(new FrameCodec(context)) //
+            .addLast(new PacketCodec(context)) //
+            .addLast(
+                new ChannelInboundHandlerAdapter() {
+                    @Override
+                    public void channelActive(ChannelHandlerContext ctx)
+                        throws Exception {
+                        ctx.writeAndFlush(new Ping()).sync();
+                    }
+
+                    @Override
+                    public void channelRead(
+                        ChannelHandlerContext ctx,
+                        Object msg
+                    ) throws Exception {
+                        if (msg instanceof Pong) {
+                            logger.info("GOT PONG");
+                            ctx.close();
+                        }
+                    }
+
+                    @Override
+                    public void channelInactive(ChannelHandlerContext ctx)
+                        throws Exception {
+                        logger.info("channel done");
+                    }
+                }
+            );
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/package-info.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/package-info.java
new file mode 100644
index 0000000..b9e9579
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/client/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * The client code for the ping/pong example.
+ */
+package crow.game.examples.pingpong.client;
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Constants.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Constants.java
new file mode 100644
index 0000000..74237bc
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Constants.java
@@ -0,0 +1,9 @@
+package crow.game.examples.pingpong.proto;
+
+public class Constants {
+
+    public static final int LISTEN_PORT = 9991;
+
+    public static final short PING_PREFIX = (short) 0x9901;
+    public static final short PONG_PREFIX = (short) 0x9902;
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Ping.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Ping.java
new file mode 100644
index 0000000..5bb84d2
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Ping.java
@@ -0,0 +1,6 @@
+package crow.game.examples.pingpong.proto;
+
+/**
+ * The super simple Ping request POJO
+ */
+public class Ping {}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PingPacketHandler.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PingPacketHandler.java
new file mode 100644
index 0000000..2c3e83e
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PingPacketHandler.java
@@ -0,0 +1,28 @@
+package crow.game.examples.pingpong.proto;
+
+import crow.game.codec.Context;
+import crow.game.codec.PacketHandler;
+import crow.game.codec.PacketRule;
+import io.netty.buffer.ByteBuf;
+
+/**
+ * This is the packet handler for the Ping message. It, ironically, is empty
+ * since Ping is a 0-byte message (ignoring packet id) so this handler is
+ * only responsible for constructing and defining the packet details.
+ */
+public class PingPacketHandler implements PacketHandler<Ping> {
+
+    @Override
+    public Ping fromPacket(Context ctx, ByteBuf buffer) throws Exception {
+        return new Ping();
+    }
+
+    @Override
+    public void toPacket(Context ctx, ByteBuf buffer, Ping src)
+        throws Exception {}
+
+    @Override
+    public PacketRule rule(Context ctx) {
+        return PacketRule.Static(Constants.PING_PREFIX, 0, Ping.class);
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Pong.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Pong.java
new file mode 100644
index 0000000..bc1f71f
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/Pong.java
@@ -0,0 +1,6 @@
+package crow.game.examples.pingpong.proto;
+
+/**
+ * The super simple Pong response POJO
+ */
+public class Pong {}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PongPacketHandler.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PongPacketHandler.java
new file mode 100644
index 0000000..4854fa8
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/PongPacketHandler.java
@@ -0,0 +1,27 @@
+package crow.game.examples.pingpong.proto;
+
+import crow.game.codec.Context;
+import crow.game.codec.PacketHandler;
+import crow.game.codec.PacketRule;
+import io.netty.buffer.ByteBuf;
+
+/**
+ * This is the packet handler for the Pong message. It, ironically, is empty
+ * since Pong is a 0-byte message (ignoring packet id) so this handler is
+ * only responsible for constructing and defining the packet details.
+ */
+public class PongPacketHandler implements PacketHandler<Pong> {
+
+    @Override
+    public Pong fromPacket(Context ctx, ByteBuf buffer) {
+        return new Pong();
+    }
+
+    @Override
+    public void toPacket(Context ctx, ByteBuf buffer, Pong src) {}
+
+    @Override
+    public PacketRule rule(Context ctx) {
+        return PacketRule.Static(Constants.PONG_PREFIX, 0, Ping.class);
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/RootContext.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/RootContext.java
new file mode 100644
index 0000000..0de4000
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/RootContext.java
@@ -0,0 +1,65 @@
+package crow.game.examples.pingpong.proto;
+
+import crow.game.codec.Context;
+import crow.game.codec.PacketHandler;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * The root context is the context implementation for the Ping/Pong
+ * server and defines the packet version and provides the {@link PacketHandler}s.
+ * <p>
+ * More complicated client/server implementations could use spring or guice or SPI
+ * to lookup packet POJOs and {@link PacketHandler}s but this one is too simple
+ * to justify that level of work.
+ * <p>
+ * Ideally we will have a common server or client module that implements a Context
+ * that does use guice or spring and then that module is just marked as "requiring
+ * injection library".
+ *
+ */
+public class RootContext implements Context {
+
+    protected final PacketHandler<Ping> pingPacketHandler;
+    protected final PacketHandler<Pong> pongPacketHandler;
+
+    public RootContext() {
+        this.pingPacketHandler = new PingPacketHandler();
+        this.pongPacketHandler = new PongPacketHandler();
+    }
+
+    public Context.Source callingSource() {
+        return Context.Source.INIT;
+    }
+
+    public int supportedProtocolVersion() {
+        return 20231115;
+    }
+
+    @Override
+    public List<PacketHandler<?>> lookupPacketHandler(int prefix) {
+        if (prefix == Constants.PING_PREFIX) {
+            return Arrays.asList(pingPacketHandler);
+        } else if (prefix == Constants.PONG_PREFIX) {
+            return Arrays.asList(pongPacketHandler);
+        } else {
+            return Arrays.asList();
+        }
+    }
+
+    @Override
+    public List<PacketHandler<?>> lookupPacketHandler(Class<?> clz) {
+        if (clz.isAssignableFrom(Ping.class)) {
+            return Arrays.asList(pingPacketHandler);
+        } else if (clz.isAssignableFrom(Pong.class)) {
+            return Arrays.asList(pongPacketHandler);
+        } else {
+            return Arrays.asList();
+        }
+    }
+
+    @Override
+    public Object underlyingObject() {
+        return this;
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/package-info.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/package-info.java
new file mode 100644
index 0000000..48ca944
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/proto/package-info.java
@@ -0,0 +1,16 @@
+/**
+ * This is the ping pong protocol package and would, in more complex scenarios,
+ * be in its own JAR. It contains the POJOs and PacketHandlers for both client
+ * and server needs and is meant to be included in both sides to support bidirectional
+ * communication.
+ * <p>
+ * If you look at the {@link crow.game.examples.pingpong.proto.Ping} and {@link crow.game.examples.pingpong.proto.Pong}
+ * objects and their corresponding PacketHandlers, you'll notice there is no real
+ * different between the two. The request/response model in this system is not defined(1) so
+ * objects can be both, providing their PacketHandlers implement both decode and encode.
+ * <p>
+ * (1) NOTE: This may change in the future!
+ * <p>
+ * @see crow.game.examples.pingpong.proto.RootContext
+ */
+package crow.game.examples.pingpong.proto;
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/MainServer.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/MainServer.java
new file mode 100644
index 0000000..4536d2b
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/MainServer.java
@@ -0,0 +1,56 @@
+package crow.game.examples.pingpong.server;
+
+import crow.game.codec.Context;
+import crow.game.examples.pingpong.proto.Constants;
+import crow.game.examples.pingpong.proto.RootContext;
+import crow.game.examples.pingpong.utils.PrefixThreadFactory;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This wraps the server into a runnable that can be run as a main entrypoint
+ * OR passed into a Thread.
+ *
+ * @see crow.game.examples.pingpong.utils.Main
+ */
+public class MainServer implements Runnable {
+
+    public static final Logger logger = LoggerFactory.getLogger(
+        MainServer.class
+    );
+
+    public static void main(String[] args) {
+        new MainServer().run();
+    }
+
+    public void run() {
+        EventLoopGroup bossGroup = new NioEventLoopGroup(
+            new PrefixThreadFactory("server-boss")
+        );
+        EventLoopGroup workerGroup = new NioEventLoopGroup(
+            new PrefixThreadFactory("server-worker")
+        );
+        Context context = new RootContext();
+        try {
+            ServerBootstrap b = new ServerBootstrap(); //
+            b
+                .group(bossGroup, workerGroup) //
+                .channel(NioServerSocketChannel.class) //
+                .childHandler(new PingPongServerInitializer(context)) //
+                .option(ChannelOption.SO_BACKLOG, 128) //
+                .childOption(ChannelOption.SO_KEEPALIVE, true);
+
+            ChannelFuture f = b.bind(Constants.LISTEN_PORT).sync(); // (7)
+            f.channel().closeFuture().sync();
+        } catch (InterruptedException e) {} finally {
+            workerGroup.shutdownGracefully();
+            bossGroup.shutdownGracefully();
+        }
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/PingPongServerInitializer.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/PingPongServerInitializer.java
new file mode 100644
index 0000000..df76cd9
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/PingPongServerInitializer.java
@@ -0,0 +1,78 @@
+package crow.game.examples.pingpong.server;
+
+import crow.game.codec.Context;
+import crow.game.codec.netty.FrameCodec;
+import crow.game.codec.netty.PacketCodec;
+import crow.game.examples.pingpong.proto.Ping;
+import crow.game.examples.pingpong.proto.Pong;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.handler.codec.DecoderException;
+import io.netty.handler.codec.UnsupportedMessageTypeException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Netty server logic for the ping pong example. it registers
+ * the standard {@link FrameCodec} and {@link PacketCodec} then
+ * just waits for a {@link Ping} object to come from a client.
+ * <p>
+ * The bottom of the pipeline is a {@link SimpleChannelInboundHandler} with
+ * two functions: listen for Ping messages and handle exceptions thrown by the
+ * codecs and report and drop the connection as needed.
+ */
+public class PingPongServerInitializer
+    extends ChannelInitializer<SocketChannel> {
+
+    public static final Logger logger = LoggerFactory.getLogger(
+        PingPongServerInitializer.class
+    );
+
+    protected final Context context;
+
+    public PingPongServerInitializer(Context ctx) {
+        this.context = ctx;
+    }
+
+    @Override
+    protected void initChannel(SocketChannel ch) {
+        ch
+            .pipeline() //
+            .addLast(new FrameCodec(context)) //
+            .addLast(new PacketCodec(context)) //
+            .addLast(
+                new SimpleChannelInboundHandler<Ping>() {
+                    @Override
+                    protected void channelRead0(
+                        ChannelHandlerContext channelHandlerContext,
+                        Ping ping
+                    ) {
+                        logger.info("GOT PING, RESPONDING");
+                        channelHandlerContext.writeAndFlush(new Pong());
+                    }
+
+                    @Override
+                    public void exceptionCaught(
+                        ChannelHandlerContext ctx,
+                        Throwable cause
+                    ) {
+                        if (cause instanceof DecoderException) {
+                            if (
+                                cause.getCause() instanceof UnsupportedMessageTypeException
+                            ) {
+                                logger.warn(
+                                    "dropping connection due to malformed packet"
+                                );
+                                ctx.close();
+                                return;
+                            }
+                        }
+
+                        logger.error("error", cause);
+                    }
+                }
+            );
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/package-info.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/package-info.java
new file mode 100644
index 0000000..e4bc059
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/server/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * The server code for the ping/pong example.
+ */
+package crow.game.examples.pingpong.server;
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/Main.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/Main.java
new file mode 100644
index 0000000..f942842
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/Main.java
@@ -0,0 +1,26 @@
+package crow.game.examples.pingpong.utils;
+
+import crow.game.examples.pingpong.client.MainClient;
+import crow.game.examples.pingpong.server.MainServer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This main runs both client and server as separate threads.
+ */
+public class Main {
+
+    public static void main(String[] args) {
+        Logger l = LoggerFactory.getLogger(Main.class);
+        l.info("running...");
+        Thread thr1 = new Thread(new MainServer(), "server");
+        thr1.start();
+        try {
+            Thread.sleep(3000);
+        } catch (InterruptedException e) {
+            throw new RuntimeException(e);
+        }
+        new MainClient().run();
+        thr1.interrupt();
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/PrefixThreadFactory.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/PrefixThreadFactory.java
new file mode 100644
index 0000000..a2d397e
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/PrefixThreadFactory.java
@@ -0,0 +1,30 @@
+package crow.game.examples.pingpong.utils;
+
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Thread factory which prefixes the thread with the given
+ * name and an incremental integer. Useful for debugging
+ * thread pools built by Netty.
+ */
+public class PrefixThreadFactory implements ThreadFactory {
+
+    AtomicInteger at;
+
+    String prefix;
+
+    public PrefixThreadFactory(String p) {
+        at = new AtomicInteger(0);
+        prefix = p;
+    }
+
+    @Override
+    public Thread newThread(Runnable arg0) {
+        Thread thr = new Thread(
+            arg0,
+            String.format("%s-%d", prefix, at.getAndIncrement())
+        );
+        return thr;
+    }
+}
diff --git a/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/package-info.java b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/package-info.java
new file mode 100644
index 0000000..bcfb2f8
--- /dev/null
+++ b/crow.game.examples.pingpong/src/main/java/crow/game/examples/pingpong/utils/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Shared utilities and launcher for the ping pong example.
+ */
+package crow.game.examples.pingpong.utils;
diff --git a/crow.game.site/src/site/markdown/devlog.md b/crow.game.site/src/site/markdown/devlog.md
index dbac497..2bd9b72 100644
--- a/crow.game.site/src/site/markdown/devlog.md
+++ b/crow.game.site/src/site/markdown/devlog.md
@@ -1,3 +1,5 @@
 ## Development Log
 
- * [devlog/2023-11-24.html](devlog/2023-11-24.html) - b0in
\ No newline at end of file
+* [devlog/2023-11-24.html](devlog/2023-11-24.html) - b0in
+* [devlog/2023-11-26.html](devlog/2023-11-26.html) - b0in
+
diff --git a/crow.game.site/src/site/markdown/devlog/2023-11-24.md b/crow.game.site/src/site/markdown/devlog/2023-11-24.md
index aca624c..6bfbd94 100644
--- a/crow.game.site/src/site/markdown/devlog/2023-11-24.md
+++ b/crow.game.site/src/site/markdown/devlog/2023-11-24.md
@@ -100,4 +100,5 @@ public class PingPacketHandler implements PacketHandler<Ping> {
 		return PacketRule.Static(0x2719, /*length=*/0, Ping.class);
 	}
 }
-```
\ No newline at end of file
+```
+
diff --git a/crow.game.site/src/site/markdown/devlog/2023-11-26.md b/crow.game.site/src/site/markdown/devlog/2023-11-26.md
new file mode 100644
index 0000000..bda181c
--- /dev/null
+++ b/crow.game.site/src/site/markdown/devlog/2023-11-26.md
@@ -0,0 +1,70 @@
+## Development Log - 2023-11-26
+
+Author: b0in
+
+### The second and third modules!
+
+The second module is now done. The first module, `crow.game.codec.netty`, is the FrameCodec and PacketCodec implementations
+which allow a netty client and server listen for messages. The second module,
+`crow.game.examples.pingpong` is an example of a TCP client and server using
+the two core modules implemented now.
+
+### Module Statuses
+
+From the previous post [./2023-11-24.html](./2023-11-24.html), but just
+the modules which have changed or added.
+
+#### crow.game.codec.api - [95%]
+
+We tweaked this one to use 'short' type for packet ID everywhere, though
+I'm worried this is going to be a pain due to signed/unsigned issues. My
+sample packets were 0x99NN which rolled over. This might not be a problem
+in the future but, we will see.
+
+#### crow.game.codec.netty - [90%]
+
+Working! Not much to say as this package is incredibly simple. I would like
+to move all ByteBuf references in codec.api into this package, and either use
+a shared interface in codec.api OR use nio ByteBuffer. Not sure which way
+I will go yet.
+
+#### crow.game.examples.pingpong - [90%]
+
+An example application using Netty, slf4j, and not much else.
+The server merely waits for Ping packets and responds with Pongs and
+the client just connects, sends Ping, then disconnects when it receives
+the Pong.
+
+Here is the results of running this example:
+
+```
+[main] INFO utils.Main - running...
+[client-worker-0] INFO client.PingPongClientInitializer - initializing channel
+[server-worker-0] INFO server.PingPongServerInitializer - GOT PING, RESPONDING
+[client-worker-0] INFO client.PingPongClientInitializer - GOT PONG
+[client-worker-0] INFO client.PingPongClientInitializer - channel done
+[main] INFO client.MainClient - client finished
+
+Process finished with exit code 0
+```
+
+and to show how you would put it all together, here is the initChannel implementation
+for the server (without the context and exception handling):
+
+```java
+class PingPongServerInitializer {
+    @Override
+    protected void initChannel(SocketChannel ch) {
+        ch.pipeline() //
+          .addLast(new FrameCodec(context)) //
+          .addLast(new PacketCodec(context)) //
+          .addLast(new SimpleChannelInboundHandler<Ping>() {
+              @Override
+              void channelRead0(ChannelHandlerContext ctx, Ping ping) {
+                  ctx.writeAndFlush(new Pong());
+              }
+          });
+    }
+}
+```
+
diff --git a/pom.xml b/pom.xml
index 0a65414..a8c83ff 100644
--- a/pom.xml
+++ b/pom.xml
@@ -31,6 +31,8 @@
   <modules>
     <module>crow.game.site</module>
     <module>crow.game.codec.api</module>
+    <module>crow.game.codec.netty</module>
+    <module>crow.game.examples.pingpong</module>
   </modules>
   <scm>
     <connection>scm:git:https://b0in.xyz/crow/crow-latest-git.tar.gz</connection>
@@ -58,6 +60,12 @@
               <sortPlugins>groupId,artifactId</sortPlugins>
             </sortPom>
           </pom>
+          <markdown>
+            <includes>
+              <include>**/*.md</include>
+            </includes>
+            <flexmark></flexmark>
+          </markdown>
           <formats>
             <format>
               <includes>