From e58b7e9f01a80863b88ef180eef7cbe81f903700 Mon Sep 17 00:00:00 2001
From: Andrei Mihu <andrei@heroiclabs.com>
Date: Mon, 12 Mar 2018 17:54:10 +0000
Subject: [PATCH] Authoritative multiplayer and match listings. (#171)

---
 api/api.pb.go                                 | 605 +++++++++------
 api/api.pb.gw.go                              |  50 ++
 api/api.proto                                 |  36 +
 api/api.swagger.json                          |  95 +++
 data/modules/clientrpc.lua                    |   6 +
 data/modules/debug_utils.lua                  |  45 ++
 data/modules/match.lua                        | 211 +++++
 data/modules/match_init.lua                   |  17 +
 main.go                                       |  32 +-
 migrate/migrate-packr.go                      |   4 +-
 migrate/sql/20180103142001_initial_schema.sql |   6 +-
 rtapi/realtime.pb.go                          | 224 ++++--
 rtapi/realtime.proto                          |  34 +-
 server/api.go                                 |  26 +-
 server/api_authenticate.go                    |   2 +-
 server/api_match.go                           |  50 ++
 server/api_rpc.go                             |   6 +-
 server/config.go                              |  50 +-
 server/match_handler.go                       | 723 ++++++++++++++++++
 server/match_registry.go                      | 346 +++++++++
 server/message_router.go                      |  37 +-
 server/pipeline.go                            |  40 +-
 server/pipeline_match.go                      | 174 ++++-
 server/pipeline_rpc.go                        |   2 +-
 server/runtime.go                             | 188 ++---
 server/runtime_loadlib.go                     | 131 ++++
 server/runtime_lua_context.go                 |  29 +-
 server/runtime_module_cache.go                | 101 +++
 server/runtime_nakama_module.go               | 194 ++++-
 server/runtime_oslib.go                       |   2 +-
 server/session_registry.go                    |   6 +-
 server/session_ws.go                          |  49 +-
 server/socket_ws.go                           |  11 +-
 server/tracker.go                             | 341 ++++++---
 34 files changed, 3167 insertions(+), 706 deletions(-)
 create mode 100644 data/modules/debug_utils.lua
 create mode 100644 data/modules/match.lua
 create mode 100644 data/modules/match_init.lua
 create mode 100644 server/api_match.go
 create mode 100644 server/match_handler.go
 create mode 100644 server/match_registry.go
 create mode 100644 server/runtime_loadlib.go
 create mode 100644 server/runtime_module_cache.go

diff --git a/api/api.pb.go b/api/api.pb.go
index 33ff6cfb2..f63520ac9 100644
--- a/api/api.pb.go
+++ b/api/api.pb.go
@@ -38,8 +38,11 @@ It has these top-level messages:
 	Groups
 	ImportFacebookFriendsRequest
 	LinkFacebookRequest
+	ListMatchesRequest
 	ListNotificationsRequest
 	ListStorageObjectsRequest
+	Match
+	MatchList
 	Notification
 	NotificationList
 	ReadStorageObjectId
@@ -1240,6 +1243,59 @@ func (m *LinkFacebookRequest) GetImport() *google_protobuf3.BoolValue {
 	return nil
 }
 
+type ListMatchesRequest struct {
+	// Limit the number of returned matches.
+	Limit *google_protobuf3.Int32Value `protobuf:"bytes,1,opt,name=limit" json:"limit,omitempty"`
+	// Authoritative or relayed matches.
+	Authoritative *google_protobuf3.BoolValue `protobuf:"bytes,2,opt,name=authoritative" json:"authoritative,omitempty"`
+	// Label filter.
+	Label *google_protobuf3.StringValue `protobuf:"bytes,3,opt,name=label" json:"label,omitempty"`
+	// Minimum user count.
+	MinSize *google_protobuf3.Int32Value `protobuf:"bytes,4,opt,name=min_size,json=minSize" json:"min_size,omitempty"`
+	// Maximum user count.
+	MaxSize *google_protobuf3.Int32Value `protobuf:"bytes,5,opt,name=max_size,json=maxSize" json:"max_size,omitempty"`
+}
+
+func (m *ListMatchesRequest) Reset()                    { *m = ListMatchesRequest{} }
+func (m *ListMatchesRequest) String() string            { return proto.CompactTextString(m) }
+func (*ListMatchesRequest) ProtoMessage()               {}
+func (*ListMatchesRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{30} }
+
+func (m *ListMatchesRequest) GetLimit() *google_protobuf3.Int32Value {
+	if m != nil {
+		return m.Limit
+	}
+	return nil
+}
+
+func (m *ListMatchesRequest) GetAuthoritative() *google_protobuf3.BoolValue {
+	if m != nil {
+		return m.Authoritative
+	}
+	return nil
+}
+
+func (m *ListMatchesRequest) GetLabel() *google_protobuf3.StringValue {
+	if m != nil {
+		return m.Label
+	}
+	return nil
+}
+
+func (m *ListMatchesRequest) GetMinSize() *google_protobuf3.Int32Value {
+	if m != nil {
+		return m.MinSize
+	}
+	return nil
+}
+
+func (m *ListMatchesRequest) GetMaxSize() *google_protobuf3.Int32Value {
+	if m != nil {
+		return m.MaxSize
+	}
+	return nil
+}
+
 // Get a list of unexpired notifications.
 type ListNotificationsRequest struct {
 	// The number of notifications to get. Between 1 and 100.
@@ -1251,7 +1307,7 @@ type ListNotificationsRequest struct {
 func (m *ListNotificationsRequest) Reset()                    { *m = ListNotificationsRequest{} }
 func (m *ListNotificationsRequest) String() string            { return proto.CompactTextString(m) }
 func (*ListNotificationsRequest) ProtoMessage()               {}
-func (*ListNotificationsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{30} }
+func (*ListNotificationsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{31} }
 
 func (m *ListNotificationsRequest) GetLimit() *google_protobuf3.Int32Value {
 	if m != nil {
@@ -1282,7 +1338,7 @@ type ListStorageObjectsRequest struct {
 func (m *ListStorageObjectsRequest) Reset()                    { *m = ListStorageObjectsRequest{} }
 func (m *ListStorageObjectsRequest) String() string            { return proto.CompactTextString(m) }
 func (*ListStorageObjectsRequest) ProtoMessage()               {}
-func (*ListStorageObjectsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{31} }
+func (*ListStorageObjectsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{32} }
 
 func (m *ListStorageObjectsRequest) GetUserId() string {
 	if m != nil {
@@ -1312,6 +1368,69 @@ func (m *ListStorageObjectsRequest) GetCursor() string {
 	return ""
 }
 
+// Represents a realtime match.
+type Match struct {
+	// The ID of the match, can be used to join.
+	MatchId string `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
+	// True if it's an server-managed authoritative match, false otherwise.
+	Authoritative bool `protobuf:"varint,2,opt,name=authoritative" json:"authoritative,omitempty"`
+	// Match label, if any.
+	Label *google_protobuf3.StringValue `protobuf:"bytes,3,opt,name=label" json:"label,omitempty"`
+	// Current number of users in the match.
+	Size int32 `protobuf:"varint,4,opt,name=size" json:"size,omitempty"`
+}
+
+func (m *Match) Reset()                    { *m = Match{} }
+func (m *Match) String() string            { return proto.CompactTextString(m) }
+func (*Match) ProtoMessage()               {}
+func (*Match) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{33} }
+
+func (m *Match) GetMatchId() string {
+	if m != nil {
+		return m.MatchId
+	}
+	return ""
+}
+
+func (m *Match) GetAuthoritative() bool {
+	if m != nil {
+		return m.Authoritative
+	}
+	return false
+}
+
+func (m *Match) GetLabel() *google_protobuf3.StringValue {
+	if m != nil {
+		return m.Label
+	}
+	return nil
+}
+
+func (m *Match) GetSize() int32 {
+	if m != nil {
+		return m.Size
+	}
+	return 0
+}
+
+// A list of realtime matches.
+type MatchList struct {
+	// A number of matches corresponding to a list operation.
+	Matches []*Match `protobuf:"bytes,1,rep,name=matches" json:"matches,omitempty"`
+}
+
+func (m *MatchList) Reset()                    { *m = MatchList{} }
+func (m *MatchList) String() string            { return proto.CompactTextString(m) }
+func (*MatchList) ProtoMessage()               {}
+func (*MatchList) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{34} }
+
+func (m *MatchList) GetMatches() []*Match {
+	if m != nil {
+		return m.Matches
+	}
+	return nil
+}
+
 // A notification in the server.
 type Notification struct {
 	// ID of the Notification.
@@ -1333,7 +1452,7 @@ type Notification struct {
 func (m *Notification) Reset()                    { *m = Notification{} }
 func (m *Notification) String() string            { return proto.CompactTextString(m) }
 func (*Notification) ProtoMessage()               {}
-func (*Notification) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{32} }
+func (*Notification) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{35} }
 
 func (m *Notification) GetId() string {
 	if m != nil {
@@ -1395,7 +1514,7 @@ type NotificationList struct {
 func (m *NotificationList) Reset()                    { *m = NotificationList{} }
 func (m *NotificationList) String() string            { return proto.CompactTextString(m) }
 func (*NotificationList) ProtoMessage()               {}
-func (*NotificationList) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{33} }
+func (*NotificationList) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{36} }
 
 func (m *NotificationList) GetNotifications() []*Notification {
 	if m != nil {
@@ -1424,7 +1543,7 @@ type ReadStorageObjectId struct {
 func (m *ReadStorageObjectId) Reset()                    { *m = ReadStorageObjectId{} }
 func (m *ReadStorageObjectId) String() string            { return proto.CompactTextString(m) }
 func (*ReadStorageObjectId) ProtoMessage()               {}
-func (*ReadStorageObjectId) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{34} }
+func (*ReadStorageObjectId) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{37} }
 
 func (m *ReadStorageObjectId) GetCollection() string {
 	if m != nil {
@@ -1456,7 +1575,7 @@ type ReadStorageObjectsRequest struct {
 func (m *ReadStorageObjectsRequest) Reset()                    { *m = ReadStorageObjectsRequest{} }
 func (m *ReadStorageObjectsRequest) String() string            { return proto.CompactTextString(m) }
 func (*ReadStorageObjectsRequest) ProtoMessage()               {}
-func (*ReadStorageObjectsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{35} }
+func (*ReadStorageObjectsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{38} }
 
 func (m *ReadStorageObjectsRequest) GetObjectIds() []*ReadStorageObjectId {
 	if m != nil {
@@ -1478,7 +1597,7 @@ type Rpc struct {
 func (m *Rpc) Reset()                    { *m = Rpc{} }
 func (m *Rpc) String() string            { return proto.CompactTextString(m) }
 func (*Rpc) ProtoMessage()               {}
-func (*Rpc) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{36} }
+func (*Rpc) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{39} }
 
 func (m *Rpc) GetId() string {
 	if m != nil {
@@ -1512,7 +1631,7 @@ type Session struct {
 func (m *Session) Reset()                    { *m = Session{} }
 func (m *Session) String() string            { return proto.CompactTextString(m) }
 func (*Session) ProtoMessage()               {}
-func (*Session) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{37} }
+func (*Session) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{40} }
 
 func (m *Session) GetToken() string {
 	if m != nil {
@@ -1553,7 +1672,7 @@ type StorageObject struct {
 func (m *StorageObject) Reset()                    { *m = StorageObject{} }
 func (m *StorageObject) String() string            { return proto.CompactTextString(m) }
 func (*StorageObject) ProtoMessage()               {}
-func (*StorageObject) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{38} }
+func (*StorageObject) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{41} }
 
 func (m *StorageObject) GetCollection() string {
 	if m != nil {
@@ -1631,7 +1750,7 @@ type StorageObjectAck struct {
 func (m *StorageObjectAck) Reset()                    { *m = StorageObjectAck{} }
 func (m *StorageObjectAck) String() string            { return proto.CompactTextString(m) }
 func (*StorageObjectAck) ProtoMessage()               {}
-func (*StorageObjectAck) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{39} }
+func (*StorageObjectAck) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{42} }
 
 func (m *StorageObjectAck) GetCollection() string {
 	if m != nil {
@@ -1663,7 +1782,7 @@ type StorageObjectAcks struct {
 func (m *StorageObjectAcks) Reset()                    { *m = StorageObjectAcks{} }
 func (m *StorageObjectAcks) String() string            { return proto.CompactTextString(m) }
 func (*StorageObjectAcks) ProtoMessage()               {}
-func (*StorageObjectAcks) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{40} }
+func (*StorageObjectAcks) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{43} }
 
 func (m *StorageObjectAcks) GetAcks() []*StorageObjectAck {
 	if m != nil {
@@ -1681,7 +1800,7 @@ type StorageObjects struct {
 func (m *StorageObjects) Reset()                    { *m = StorageObjects{} }
 func (m *StorageObjects) String() string            { return proto.CompactTextString(m) }
 func (*StorageObjects) ProtoMessage()               {}
-func (*StorageObjects) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{41} }
+func (*StorageObjects) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{44} }
 
 func (m *StorageObjects) GetObjects() []*StorageObject {
 	if m != nil {
@@ -1701,7 +1820,7 @@ type StorageObjectList struct {
 func (m *StorageObjectList) Reset()                    { *m = StorageObjectList{} }
 func (m *StorageObjectList) String() string            { return proto.CompactTextString(m) }
 func (*StorageObjectList) ProtoMessage()               {}
-func (*StorageObjectList) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{42} }
+func (*StorageObjectList) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{45} }
 
 func (m *StorageObjectList) GetObjects() []*StorageObject {
 	if m != nil {
@@ -1736,7 +1855,7 @@ type UpdateAccountRequest struct {
 func (m *UpdateAccountRequest) Reset()                    { *m = UpdateAccountRequest{} }
 func (m *UpdateAccountRequest) String() string            { return proto.CompactTextString(m) }
 func (*UpdateAccountRequest) ProtoMessage()               {}
-func (*UpdateAccountRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{43} }
+func (*UpdateAccountRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{46} }
 
 func (m *UpdateAccountRequest) GetUsername() *google_protobuf3.StringValue {
 	if m != nil {
@@ -1819,7 +1938,7 @@ type User struct {
 func (m *User) Reset()                    { *m = User{} }
 func (m *User) String() string            { return proto.CompactTextString(m) }
 func (*User) ProtoMessage()               {}
-func (*User) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{44} }
+func (*User) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{47} }
 
 func (m *User) GetId() string {
 	if m != nil {
@@ -1942,7 +2061,7 @@ type Users struct {
 func (m *Users) Reset()                    { *m = Users{} }
 func (m *Users) String() string            { return proto.CompactTextString(m) }
 func (*Users) ProtoMessage()               {}
-func (*Users) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{45} }
+func (*Users) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{48} }
 
 func (m *Users) GetUsers() []*User {
 	if m != nil {
@@ -1970,7 +2089,7 @@ type WriteStorageObject struct {
 func (m *WriteStorageObject) Reset()                    { *m = WriteStorageObject{} }
 func (m *WriteStorageObject) String() string            { return proto.CompactTextString(m) }
 func (*WriteStorageObject) ProtoMessage()               {}
-func (*WriteStorageObject) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{46} }
+func (*WriteStorageObject) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{49} }
 
 func (m *WriteStorageObject) GetCollection() string {
 	if m != nil {
@@ -2023,7 +2142,7 @@ type WriteStorageObjectsRequest struct {
 func (m *WriteStorageObjectsRequest) Reset()                    { *m = WriteStorageObjectsRequest{} }
 func (m *WriteStorageObjectsRequest) String() string            { return proto.CompactTextString(m) }
 func (*WriteStorageObjectsRequest) ProtoMessage()               {}
-func (*WriteStorageObjectsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{47} }
+func (*WriteStorageObjectsRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{50} }
 
 func (m *WriteStorageObjectsRequest) GetObjects() []*WriteStorageObject {
 	if m != nil {
@@ -2064,8 +2183,11 @@ func init() {
 	proto.RegisterType((*Groups)(nil), "nakama.api.Groups")
 	proto.RegisterType((*ImportFacebookFriendsRequest)(nil), "nakama.api.ImportFacebookFriendsRequest")
 	proto.RegisterType((*LinkFacebookRequest)(nil), "nakama.api.LinkFacebookRequest")
+	proto.RegisterType((*ListMatchesRequest)(nil), "nakama.api.ListMatchesRequest")
 	proto.RegisterType((*ListNotificationsRequest)(nil), "nakama.api.ListNotificationsRequest")
 	proto.RegisterType((*ListStorageObjectsRequest)(nil), "nakama.api.ListStorageObjectsRequest")
+	proto.RegisterType((*Match)(nil), "nakama.api.Match")
+	proto.RegisterType((*MatchList)(nil), "nakama.api.MatchList")
 	proto.RegisterType((*Notification)(nil), "nakama.api.Notification")
 	proto.RegisterType((*NotificationList)(nil), "nakama.api.NotificationList")
 	proto.RegisterType((*ReadStorageObjectId)(nil), "nakama.api.ReadStorageObjectId")
@@ -2149,6 +2271,8 @@ type NakamaClient interface {
 	LinkSteam(ctx context.Context, in *AccountSteam, opts ...grpc.CallOption) (*google_protobuf1.Empty, error)
 	// List all friends for the current user.
 	ListFriends(ctx context.Context, in *google_protobuf1.Empty, opts ...grpc.CallOption) (*Friends, error)
+	// Fetch list of running matches.
+	ListMatches(ctx context.Context, in *ListMatchesRequest, opts ...grpc.CallOption) (*MatchList, error)
 	// Fetch list of notifications.
 	ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*NotificationList, error)
 	// List collections of storage objects.
@@ -2410,6 +2534,15 @@ func (c *nakamaClient) ListFriends(ctx context.Context, in *google_protobuf1.Emp
 	return out, nil
 }
 
+func (c *nakamaClient) ListMatches(ctx context.Context, in *ListMatchesRequest, opts ...grpc.CallOption) (*MatchList, error) {
+	out := new(MatchList)
+	err := grpc.Invoke(ctx, "/nakama.api.Nakama/ListMatches", in, out, c.cc, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
 func (c *nakamaClient) ListNotifications(ctx context.Context, in *ListNotificationsRequest, opts ...grpc.CallOption) (*NotificationList, error) {
 	out := new(NotificationList)
 	err := grpc.Invoke(ctx, "/nakama.api.Nakama/ListNotifications", in, out, c.cc, opts...)
@@ -2580,6 +2713,8 @@ type NakamaServer interface {
 	LinkSteam(context.Context, *AccountSteam) (*google_protobuf1.Empty, error)
 	// List all friends for the current user.
 	ListFriends(context.Context, *google_protobuf1.Empty) (*Friends, error)
+	// Fetch list of running matches.
+	ListMatches(context.Context, *ListMatchesRequest) (*MatchList, error)
 	// Fetch list of notifications.
 	ListNotifications(context.Context, *ListNotificationsRequest) (*NotificationList, error)
 	// List collections of storage objects.
@@ -3062,6 +3197,24 @@ func _Nakama_ListFriends_Handler(srv interface{}, ctx context.Context, dec func(
 	return interceptor(ctx, in, info, handler)
 }
 
+func _Nakama_ListMatches_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListMatchesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NakamaServer).ListMatches(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/nakama.api.Nakama/ListMatches",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NakamaServer).ListMatches(ctx, req.(*ListMatchesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
 func _Nakama_ListNotifications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
 	in := new(ListNotificationsRequest)
 	if err := dec(in); err != nil {
@@ -3400,6 +3553,10 @@ var _Nakama_serviceDesc = grpc.ServiceDesc{
 			MethodName: "ListFriends",
 			Handler:    _Nakama_ListFriends_Handler,
 		},
+		{
+			MethodName: "ListMatches",
+			Handler:    _Nakama_ListMatches_Handler,
+		},
 		{
 			MethodName: "ListNotifications",
 			Handler:    _Nakama_ListNotifications_Handler,
@@ -3460,205 +3617,215 @@ var _Nakama_serviceDesc = grpc.ServiceDesc{
 func init() { proto.RegisterFile("api/api.proto", fileDescriptor0) }
 
 var fileDescriptor0 = []byte{
-	// 3188 bytes of a gzipped FileDescriptorProto
-	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x5a, 0x4b, 0x73, 0xdc, 0xc6,
-	0xb5, 0xbe, 0x98, 0x07, 0x87, 0x73, 0x86, 0x8f, 0x61, 0x53, 0x92, 0x87, 0x43, 0x3d, 0x28, 0x88,
-	0xd6, 0x83, 0xd7, 0xe6, 0xe8, 0x52, 0xf7, 0x96, 0x5d, 0xf2, 0x4d, 0x2c, 0x8a, 0x1c, 0x31, 0x13,
-	0xc9, 0x43, 0x16, 0x86, 0x94, 0x12, 0x55, 0xd9, 0x63, 0x10, 0x68, 0x0d, 0x61, 0x62, 0x00, 0x18,
-	0xc0, 0x50, 0x61, 0x5c, 0x2a, 0xa7, 0xbc, 0x49, 0x2a, 0x8b, 0x2c, 0x9c, 0x65, 0xaa, 0x92, 0x94,
-	0xb3, 0x49, 0x65, 0x95, 0xdf, 0x91, 0x4d, 0xaa, 0x92, 0x8d, 0x2b, 0xab, 0x2c, 0xb2, 0xf4, 0x3f,
-	0xc8, 0x26, 0xd5, 0x0f, 0x00, 0x8d, 0x17, 0x9f, 0x96, 0x57, 0x9c, 0x6e, 0x9c, 0xee, 0xf3, 0x75,
-	0xf7, 0x39, 0xdf, 0x39, 0x7d, 0x9a, 0x30, 0xa9, 0x3a, 0x46, 0x4b, 0x75, 0x8c, 0x65, 0xc7, 0xb5,
-	0x7d, 0x1b, 0x81, 0xa5, 0xee, 0xab, 0x43, 0x75, 0x59, 0x75, 0x8c, 0xe6, 0xe5, 0x81, 0x6d, 0x0f,
-	0x4c, 0xdc, 0xa2, 0x12, 0x96, 0x65, 0xfb, 0xaa, 0x6f, 0xd8, 0x96, 0xc7, 0x24, 0x9b, 0xf3, 0xfc,
-	0x2b, 0x6d, 0xed, 0x8e, 0x5e, 0xb4, 0xf0, 0xd0, 0xf1, 0x0f, 0xf9, 0xc7, 0x6b, 0xc9, 0x8f, 0xbe,
-	0x31, 0xc4, 0x9e, 0xaf, 0x0e, 0x1d, 0x2e, 0x70, 0x35, 0x29, 0xf0, 0xd2, 0x55, 0x1d, 0x07, 0xbb,
-	0xc1, 0xec, 0x6f, 0xd1, 0x3f, 0xda, 0xdb, 0x03, 0x6c, 0xbd, 0xed, 0xbd, 0x54, 0x07, 0x03, 0xec,
-	0xb6, 0x6c, 0x87, 0xea, 0x4f, 0x63, 0x91, 0xbf, 0x91, 0xa0, 0xb2, 0xaa, 0x69, 0xf6, 0xc8, 0xf2,
-	0xd1, 0x22, 0x94, 0x46, 0x1e, 0x76, 0x1b, 0xd2, 0x82, 0x74, 0xbb, 0xb6, 0x52, 0x5f, 0x8e, 0x16,
-	0xb4, 0xbc, 0xe3, 0x61, 0x57, 0xa1, 0x5f, 0xd1, 0x25, 0x18, 0x7b, 0xa9, 0x9a, 0x26, 0xf6, 0x1b,
-	0x85, 0x05, 0xe9, 0x76, 0x55, 0xe1, 0x2d, 0x74, 0x01, 0xca, 0x78, 0xa8, 0x1a, 0x66, 0xa3, 0x48,
-	0xbb, 0x59, 0x03, 0xdd, 0x83, 0x8a, 0x8e, 0x0f, 0x0c, 0x0d, 0x7b, 0x8d, 0xd2, 0x42, 0xf1, 0x76,
-	0x6d, 0x65, 0x4e, 0x9c, 0x96, 0x6b, 0x5e, 0xa7, 0x12, 0x4a, 0x20, 0x89, 0xe6, 0xa1, 0xaa, 0x8d,
-	0x3c, 0xdf, 0x1e, 0xf6, 0x0d, 0xbd, 0x51, 0xa6, 0xd3, 0x8d, 0xb3, 0x8e, 0x8e, 0x8e, 0xde, 0x83,
-	0xda, 0x01, 0x76, 0x8d, 0x17, 0x87, 0x7d, 0xb2, 0x33, 0x8d, 0x31, 0x0a, 0xb6, 0xb9, 0xcc, 0x76,
-	0x65, 0x39, 0xd8, 0x95, 0xe5, 0xed, 0x60, 0xdb, 0x14, 0x60, 0xe2, 0xa4, 0x43, 0xbe, 0x06, 0x93,
-	0x5c, 0xe7, 0x1a, 0x9d, 0x0f, 0x4d, 0x41, 0xc1, 0xd0, 0xe9, 0x8a, 0xab, 0x4a, 0xc1, 0xd0, 0x05,
-	0x01, 0x06, 0x2a, 0x25, 0xf0, 0x00, 0x26, 0xb8, 0x40, 0x9b, 0x2e, 0x30, 0x5c, 0xb6, 0x24, 0x2e,
-	0xbb, 0x09, 0xe3, 0x8e, 0xea, 0x79, 0x2f, 0x6d, 0x57, 0xe7, 0xdb, 0x14, 0xb6, 0xe5, 0x5b, 0x30,
-	0xcd, 0x67, 0x78, 0xa4, 0x6a, 0x78, 0xd7, 0xb6, 0xf7, 0xc9, 0x24, 0xbe, 0xbd, 0x8f, 0xad, 0x60,
-	0x12, 0xda, 0x90, 0xff, 0x26, 0xc1, 0x0c, 0x97, 0xdc, 0x50, 0x87, 0x78, 0x0d, 0x5b, 0x3e, 0x76,
-	0xc9, 0xe6, 0x38, 0xa6, 0x7a, 0x88, 0xdd, 0x7e, 0x88, 0x6b, 0x9c, 0x75, 0x74, 0x74, 0xf2, 0x71,
-	0x77, 0x64, 0xe9, 0x26, 0x26, 0x1f, 0xb9, 0x62, 0xd6, 0xd1, 0xd1, 0xd1, 0x7f, 0xc3, 0x4c, 0x68,
-	0x4c, 0x7d, 0x0f, 0x6b, 0xb6, 0xa5, 0x7b, 0xf4, 0xb4, 0x8a, 0x4a, 0x3d, 0xfc, 0xd0, 0x63, 0xfd,
-	0x08, 0x41, 0xc9, 0x53, 0x4d, 0xbf, 0x51, 0xa2, 0x93, 0xd0, 0xdf, 0xe8, 0x32, 0x54, 0x3d, 0x63,
-	0x60, 0xa9, 0xfe, 0xc8, 0xc5, 0xfc, 0x5c, 0xa2, 0x0e, 0xb4, 0x08, 0x53, 0xce, 0x68, 0xd7, 0x34,
-	0xb4, 0xfe, 0x3e, 0x3e, 0xec, 0x8f, 0x5c, 0x93, 0x9e, 0x4d, 0x55, 0x99, 0x60, 0xbd, 0x8f, 0xf1,
-	0xe1, 0x8e, 0x6b, 0xca, 0x6f, 0x86, 0x1b, 0xbc, 0x41, 0x4f, 0x2c, 0x67, 0xed, 0x8b, 0xe1, 0x36,
-	0xf7, 0x7c, 0xac, 0x0e, 0x73, 0xa4, 0xd6, 0x60, 0x66, 0x55, 0xd7, 0x1f, 0xb9, 0x06, 0xb6, 0x74,
-	0x4f, 0xc1, 0x9f, 0x8e, 0xb0, 0xe7, 0xa3, 0x3a, 0x14, 0x0d, 0xdd, 0x6b, 0x48, 0x0b, 0xc5, 0xdb,
-	0x55, 0x85, 0xfc, 0x24, 0xb8, 0x89, 0xe9, 0x5a, 0xea, 0x10, 0x7b, 0x8d, 0x02, 0xed, 0x8f, 0x3a,
-	0xe4, 0xdf, 0x4b, 0x30, 0xb7, 0x3a, 0xf2, 0xf7, 0xb0, 0xe5, 0x1b, 0x9a, 0xea, 0x63, 0x66, 0x19,
-	0xc1, 0x6c, 0xf7, 0xa0, 0xa2, 0x32, 0x20, 0xdc, 0x2f, 0xb2, 0x0c, 0x98, 0x0f, 0x09, 0x24, 0xd1,
-	0x0a, 0x8c, 0x69, 0x2e, 0x56, 0x7d, 0x4c, 0xcf, 0x20, 0xcb, 0x3c, 0x1f, 0xda, 0xb6, 0xf9, 0x54,
-	0x35, 0x47, 0x58, 0xe1, 0x92, 0xc4, 0x64, 0x02, 0x4c, 0xdc, 0x85, 0xc2, 0x76, 0x0a, 0x22, 0x77,
-	0x98, 0xd3, 0x40, 0x0c, 0x7c, 0xec, 0x75, 0x41, 0xfc, 0xad, 0x04, 0x0d, 0x11, 0x22, 0xf5, 0x8e,
-	0x00, 0xe1, 0x4a, 0x12, 0x61, 0x23, 0x03, 0x21, 0x1b, 0xf1, 0xda, 0x00, 0x7e, 0x2d, 0xc1, 0xbc,
-	0x08, 0x30, 0x70, 0xbe, 0x00, 0xe3, 0xff, 0x25, 0x31, 0xce, 0x67, 0x60, 0x0c, 0x07, 0xbd, 0x2e,
-	0x98, 0x64, 0x3e, 0x63, 0xe8, 0xd8, 0x2e, 0xf3, 0xbc, 0x63, 0xe6, 0x63, 0x92, 0xf2, 0x1f, 0x25,
-	0xb8, 0x22, 0x2e, 0x2d, 0x62, 0x8b, 0x60, 0x71, 0xef, 0x24, 0x17, 0x77, 0x25, 0x63, 0x71, 0xc2,
-	0xb0, 0xef, 0xcc, 0x92, 0x19, 0x09, 0x9c, 0xca, 0x92, 0xf9, 0x90, 0xef, 0xcc, 0x92, 0x29, 0x01,
-	0x9d, 0xca, 0x92, 0xd9, 0x88, 0xd7, 0x06, 0xb0, 0x0d, 0xb3, 0x0f, 0x4d, 0x5b, 0xdb, 0x3f, 0x27,
-	0xef, 0x1d, 0x40, 0x75, 0x6d, 0x4f, 0xb5, 0x2c, 0x6c, 0x76, 0xf4, 0x64, 0x98, 0x23, 0xf4, 0xef,
-	0x1f, 0x3a, 0x0c, 0x71, 0x59, 0xa1, 0xbf, 0xe5, 0x36, 0x94, 0xb6, 0x0f, 0x1d, 0xc2, 0xd8, 0xf5,
-	0xed, 0x1f, 0x6f, 0xb5, 0xfb, 0x3b, 0xdd, 0xde, 0x56, 0x7b, 0xad, 0xf3, 0xa8, 0xd3, 0x5e, 0xaf,
-	0xff, 0x17, 0x1a, 0x87, 0x92, 0xb2, 0xb9, 0xf9, 0x41, 0x5d, 0x42, 0x08, 0xa6, 0xd6, 0x3b, 0x4a,
-	0x7b, 0x6d, 0xbb, 0xff, 0x41, 0xbb, 0xd7, 0x5b, 0xdd, 0x68, 0xd7, 0x0b, 0xa8, 0x0a, 0xe5, 0x0d,
-	0x65, 0x73, 0x67, 0xab, 0x5e, 0x94, 0x7f, 0x59, 0x80, 0xd9, 0x35, 0xba, 0xca, 0x0d, 0xd7, 0x1e,
-	0x39, 0x21, 0xfe, 0x07, 0x30, 0x36, 0xa0, 0x1d, 0x74, 0x09, 0xb5, 0x95, 0xdb, 0xe2, 0xce, 0x66,
-	0x0c, 0x58, 0xee, 0xe2, 0x97, 0xb4, 0x43, 0xe1, 0xe3, 0x9a, 0x7f, 0x96, 0x60, 0x3c, 0xe8, 0x24,
-	0x2b, 0xa0, 0xbb, 0xc7, 0xd6, 0x44, 0x7f, 0xa3, 0x05, 0xa8, 0xe9, 0xd8, 0xd3, 0x5c, 0x83, 0xe6,
-	0x43, 0x3c, 0x40, 0x8a, 0x5d, 0x68, 0x0e, 0xc6, 0x4d, 0xd5, 0x1a, 0xf4, 0x7d, 0x75, 0xc0, 0xf7,
-	0xbd, 0x42, 0xda, 0xdb, 0xea, 0x80, 0x1c, 0xc9, 0x10, 0xfb, 0xaa, 0xae, 0xfa, 0x2a, 0x8f, 0x8a,
-	0x61, 0x1b, 0x5d, 0x01, 0x50, 0x0f, 0x54, 0x5f, 0x75, 0x69, 0xdc, 0xe3, 0xa1, 0x91, 0xf5, 0xec,
-	0xb8, 0x26, 0x6a, 0x40, 0xc5, 0x71, 0x8d, 0x03, 0x62, 0x02, 0x24, 0x26, 0x8e, 0x2b, 0x41, 0x53,
-	0x7e, 0x04, 0x17, 0xd6, 0xb1, 0x89, 0x7d, 0x7c, 0xce, 0xc3, 0x5c, 0x86, 0x26, 0x9b, 0xa7, 0x6b,
-	0xfb, 0xc6, 0x0b, 0x62, 0xb7, 0x24, 0xc9, 0xcb, 0x9d, 0x4d, 0xd6, 0xe0, 0x22, 0x93, 0xef, 0xf9,
-	0xb6, 0xab, 0x0e, 0xf0, 0xe6, 0xee, 0x27, 0x58, 0xf3, 0x3b, 0x3a, 0xba, 0x0a, 0xa0, 0xd9, 0xa6,
-	0x89, 0x35, 0xba, 0x43, 0x6c, 0xf3, 0x84, 0x1e, 0x32, 0xd5, 0x3e, 0x3e, 0xe4, 0x5b, 0x47, 0x7e,
-	0x92, 0xc5, 0x1d, 0x60, 0xd7, 0x23, 0xe2, 0x7c, 0xc7, 0x78, 0x53, 0xfe, 0x08, 0xe6, 0x33, 0x94,
-	0x84, 0xa8, 0xde, 0x87, 0xaa, 0xcd, 0xd5, 0x06, 0x67, 0x7e, 0x5d, 0x3c, 0xf3, 0x4c, 0x80, 0x4a,
-	0x34, 0x46, 0xfe, 0x83, 0x04, 0x63, 0x6c, 0xdf, 0x4e, 0x98, 0xbb, 0x5e, 0x80, 0xb2, 0xe7, 0x07,
-	0x8e, 0x58, 0x56, 0x58, 0x43, 0xfe, 0x10, 0xca, 0x3d, 0xf2, 0x03, 0x5d, 0x84, 0x99, 0xde, 0xf6,
-	0xea, 0x76, 0xd2, 0xb2, 0x01, 0xc6, 0x1e, 0x29, 0x9d, 0x76, 0x77, 0xbd, 0x2e, 0xa1, 0x69, 0xa8,
-	0x75, 0xba, 0x4f, 0x3b, 0xdb, 0xed, 0x7e, 0xaf, 0xdd, 0xdd, 0xae, 0x17, 0xd0, 0x2c, 0x4c, 0xf3,
-	0x0e, 0xa5, 0xbd, 0xd6, 0xee, 0x3c, 0x6d, 0xaf, 0xd7, 0x8b, 0xa8, 0x06, 0x95, 0x87, 0x4f, 0x36,
-	0xd7, 0x1e, 0xb7, 0xd7, 0xeb, 0x25, 0xf9, 0x1d, 0xa8, 0xf0, 0xc3, 0x45, 0x6f, 0x41, 0xe5, 0x05,
-	0xfb, 0xc9, 0xd7, 0x8b, 0x44, 0xa0, 0x4c, 0x4a, 0x09, 0x44, 0x64, 0x1d, 0xa6, 0x37, 0xb0, 0x4f,
-	0xe0, 0x9f, 0xd5, 0x2c, 0xd0, 0x75, 0x98, 0x78, 0xc1, 0x43, 0x56, 0xdf, 0xa0, 0xd9, 0x1e, 0x11,
-	0xa8, 0x05, 0x7d, 0x64, 0x13, 0xbf, 0x29, 0x40, 0x99, 0x79, 0x4c, 0x92, 0x03, 0xae, 0x00, 0x50,
-	0x36, 0xb2, 0xdd, 0x28, 0x9b, 0xac, 0xf2, 0x9e, 0x8e, 0x1e, 0x3a, 0x58, 0x31, 0xdf, 0xc1, 0x4a,
-	0x47, 0x3b, 0x58, 0x39, 0xdf, 0xc1, 0xc6, 0x8e, 0x74, 0xb0, 0xca, 0x11, 0x0e, 0x36, 0x1e, 0x73,
-	0x30, 0x72, 0xe4, 0x8c, 0xae, 0xab, 0xec, 0xc8, 0x19, 0x25, 0xbf, 0x07, 0x35, 0x46, 0xb4, 0xec,
-	0x12, 0x01, 0xc7, 0x5f, 0x22, 0x98, 0x38, 0xe9, 0x20, 0x83, 0x47, 0x8e, 0x1e, 0x0e, 0xae, 0x1d,
-	0x3f, 0x98, 0x89, 0xd3, 0x1b, 0xc8, 0x3d, 0x18, 0x63, 0x2c, 0x86, 0xee, 0x24, 0xf8, 0x6e, 0x46,
-	0xb4, 0x85, 0x18, 0xb1, 0xc9, 0x3f, 0x97, 0xe0, 0x72, 0x87, 0xc6, 0xfa, 0x20, 0x01, 0x49, 0xd0,
-	0xc5, 0x19, 0x93, 0x97, 0xbb, 0x50, 0x76, 0xb1, 0xc7, 0xaf, 0x72, 0x47, 0x07, 0x26, 0x26, 0x28,
-	0xff, 0x4c, 0x82, 0xd9, 0x27, 0x86, 0xb5, 0xff, 0xed, 0x65, 0x4f, 0xa7, 0xce, 0x76, 0x7e, 0x02,
-	0x8d, 0x27, 0x86, 0xe7, 0x67, 0x12, 0xdd, 0xff, 0x40, 0xd9, 0x34, 0x86, 0x46, 0x04, 0x22, 0x39,
-	0x5d, 0xc7, 0xf2, 0xef, 0xad, 0xf0, 0x15, 0x51, 0x49, 0x74, 0x07, 0xea, 0x9a, 0xaa, 0xed, 0x61,
-	0x75, 0xd7, 0xc4, 0x7d, 0x6d, 0xe4, 0x7a, 0xb6, 0xcb, 0x6d, 0x7d, 0x3a, 0xec, 0x5f, 0xa3, 0xdd,
-	0xf2, 0xef, 0x24, 0x98, 0x23, 0xaa, 0xb3, 0xe9, 0xec, 0x0d, 0xa8, 0x10, 0xc7, 0x8b, 0xae, 0x65,
-	0x63, 0xa4, 0x99, 0xa2, 0xd4, 0x42, 0x8a, 0x52, 0x43, 0xd0, 0xc5, 0x13, 0x83, 0xbe, 0x04, 0x63,
-	0x1c, 0x2a, 0x73, 0x31, 0xde, 0x92, 0xff, 0x29, 0xc1, 0x84, 0xb8, 0x31, 0x29, 0x9f, 0x6e, 0x40,
-	0xc5, 0x1b, 0x51, 0xdc, 0x1c, 0x48, 0xd0, 0x24, 0x5f, 0x34, 0xdb, 0xf2, 0xb1, 0xe5, 0x07, 0x34,
-	0xce, 0x9b, 0xc4, 0xd1, 0x35, 0x5b, 0xc7, 0x54, 0x55, 0x59, 0xa1, 0xbf, 0xc9, 0x45, 0xd3, 0xc3,
-	0x96, 0xce, 0x96, 0xcb, 0xaf, 0xe8, 0xac, 0x83, 0x5d, 0xd1, 0x45, 0xef, 0x1a, 0x3b, 0x95, 0x77,
-	0x5d, 0x05, 0x70, 0x48, 0xfc, 0xf0, 0x28, 0x94, 0x0a, 0xf5, 0x66, 0xa1, 0x47, 0x7e, 0x05, 0x75,
-	0x71, 0x85, 0xe4, 0x3c, 0xd0, 0xf7, 0x61, 0xd2, 0x12, 0xcd, 0x81, 0x7b, 0x54, 0x2c, 0x37, 0x13,
-	0x07, 0x29, 0x71, 0xf1, 0xd3, 0xd8, 0xc0, 0xc7, 0x30, 0xab, 0x60, 0x55, 0x3f, 0x7f, 0xd8, 0x14,
-	0xcc, 0xa5, 0x28, 0x9a, 0x8b, 0xfc, 0x1c, 0xe6, 0x52, 0x1a, 0x42, 0x23, 0xfb, 0x5e, 0x3a, 0x66,
-	0x5e, 0x13, 0x57, 0x99, 0x81, 0x4d, 0x8c, 0x98, 0x3f, 0x84, 0xa2, 0xe2, 0x68, 0x59, 0x56, 0xe1,
-	0xa8, 0x87, 0xa6, 0xad, 0x06, 0x34, 0x1f, 0x34, 0x09, 0x5d, 0xef, 0xf9, 0xbe, 0x43, 0xae, 0xf4,
-	0x81, 0x59, 0x90, 0xf6, 0x63, 0x7c, 0x28, 0xff, 0x3f, 0x54, 0x7a, 0xd8, 0x23, 0x81, 0x3e, 0xfb,
-	0x76, 0x4e, 0x6c, 0x64, 0xa4, 0x3b, 0x7d, 0xf6, 0x85, 0x17, 0x23, 0x46, 0xba, 0xb3, 0x4d, 0xaf,
-	0xee, 0x5f, 0x17, 0x60, 0x32, 0x06, 0xf4, 0x5b, 0xdc, 0x42, 0x82, 0xe7, 0x80, 0xb8, 0x0b, 0xf7,
-	0x0e, 0xd6, 0x10, 0x13, 0x95, 0x72, 0x2c, 0x51, 0x41, 0xb7, 0x60, 0xda, 0xc1, 0xee, 0xd0, 0xa0,
-	0xab, 0xe9, 0xbb, 0x58, 0xd5, 0xa9, 0xd1, 0x96, 0x95, 0xa9, 0xa8, 0x9b, 0xec, 0x2c, 0x31, 0x14,
-	0x41, 0xf0, 0xa5, 0x6b, 0xf8, 0x98, 0x9a, 0x68, 0x59, 0x11, 0x26, 0x78, 0x46, 0xba, 0x93, 0x4e,
-	0x30, 0x7e, 0x9e, 0x10, 0x53, 0x3d, 0x55, 0x88, 0xf9, 0x08, 0xea, 0xb1, 0x9d, 0x5d, 0xd5, 0xf6,
-	0xbf, 0xd5, 0xb4, 0xae, 0x0d, 0x33, 0xc9, 0xf9, 0x3d, 0x74, 0x17, 0x4a, 0xaa, 0xb6, 0x1f, 0xd8,
-	0xe4, 0x65, 0xd1, 0x26, 0x93, 0xc2, 0x0a, 0x95, 0x94, 0xdb, 0x30, 0x15, 0xb7, 0x71, 0x72, 0xfd,
-	0x63, 0xa6, 0x1a, 0x4c, 0x33, 0x97, 0x3b, 0x8d, 0x12, 0x48, 0xca, 0x1f, 0x27, 0xd0, 0x50, 0x42,
-	0x38, 0xcb, 0x4c, 0x02, 0xa9, 0x16, 0x62, 0xa4, 0xfa, 0xef, 0x02, 0x5c, 0xd8, 0xa1, 0xdb, 0xcb,
-	0xe3, 0x58, 0xe0, 0x8c, 0xef, 0x0a, 0x97, 0x34, 0x16, 0x70, 0x2e, 0xa7, 0x8e, 0xa8, 0xe7, 0xbb,
-	0x86, 0x35, 0x60, 0xe4, 0x1d, 0xdd, 0xf2, 0xdf, 0x87, 0x09, 0xdd, 0xf0, 0x1c, 0x53, 0x3d, 0xec,
-	0xd3, 0xd1, 0x85, 0x13, 0x8c, 0xae, 0xf1, 0x11, 0x5d, 0x95, 0x1a, 0x88, 0x98, 0x0f, 0x15, 0x4f,
-	0x30, 0x5c, 0xc8, 0x96, 0xde, 0x11, 0x72, 0xb0, 0xd2, 0x09, 0x86, 0x86, 0x19, 0xda, 0xbb, 0x30,
-	0x6e, 0xda, 0x8c, 0x34, 0xa9, 0x0b, 0x1d, 0xbb, 0xe0, 0x40, 0x9a, 0x8c, 0x24, 0x96, 0xfc, 0x53,
-	0xdb, 0x0a, 0xe2, 0xc1, 0x31, 0x23, 0x03, 0x69, 0xf9, 0xcb, 0x12, 0x94, 0x48, 0x0e, 0x9c, 0x22,
-	0x2d, 0xf1, 0x8a, 0x5c, 0x48, 0x54, 0x51, 0xae, 0x27, 0xf6, 0xb7, 0xc8, 0x13, 0x51, 0x61, 0x07,
-	0xe3, 0x19, 0x65, 0x29, 0x99, 0x51, 0x1e, 0x9d, 0xa7, 0x86, 0xbb, 0xc0, 0xf3, 0xd4, 0x70, 0x9d,
-	0x4d, 0x61, 0x9d, 0x2c, 0x4b, 0x0d, 0xdb, 0xb1, 0xfc, 0x76, 0x3c, 0x91, 0xdf, 0x5e, 0x83, 0x9a,
-	0x90, 0xa8, 0x53, 0x87, 0xaf, 0x2a, 0x10, 0xe5, 0xe9, 0x84, 0x4c, 0xd9, 0x7e, 0x91, 0xcf, 0xc0,
-	0x46, 0xb3, 0x8e, 0x8e, 0x8e, 0x6e, 0xc0, 0xe4, 0x40, 0x1d, 0x62, 0x8d, 0x16, 0x6f, 0x88, 0x40,
-	0x8d, 0x55, 0x5e, 0xa3, 0xce, 0x0e, 0xa5, 0x72, 0xcf, 0xc7, 0x2a, 0x2d, 0xaa, 0x4f, 0xf0, 0xd8,
-	0x4f, 0xda, 0x1d, 0x9d, 0x58, 0xbe, 0x6d, 0x99, 0x86, 0x85, 0x1b, 0x93, 0x34, 0xde, 0xf2, 0x16,
-	0xd9, 0x23, 0xac, 0x0f, 0x70, 0x9f, 0x25, 0x76, 0x53, 0x94, 0xe8, 0xaa, 0xa4, 0x67, 0x2d, 0x2b,
-	0x8b, 0x9e, 0x3e, 0x0f, 0xc5, 0xd5, 0x4f, 0x45, 0x71, 0x2d, 0x28, 0xd3, 0x7b, 0x11, 0xba, 0x09,
-	0x65, 0x72, 0xe8, 0x81, 0x9b, 0xa7, 0x2f, 0x7e, 0xec, 0xb3, 0xfc, 0x17, 0x09, 0x10, 0xe5, 0xe5,
-	0xf3, 0xc6, 0x9c, 0x30, 0xb4, 0x14, 0x73, 0x42, 0x4b, 0xe9, 0xd8, 0xd0, 0x52, 0x3e, 0x71, 0x68,
-	0x19, 0xcb, 0x0c, 0x2d, 0xf2, 0x53, 0x68, 0xa6, 0xd7, 0xe2, 0x45, 0xac, 0x94, 0xe0, 0xbe, 0xab,
-	0xe2, 0xa6, 0xa4, 0x07, 0x86, 0x04, 0xb8, 0x64, 0xc1, 0x45, 0xfe, 0x65, 0x2b, 0x8e, 0xed, 0x16,
-	0xdc, 0xe8, 0x6d, 0x6f, 0x2a, 0xab, 0x1b, 0xed, 0xfe, 0x56, 0x5b, 0xf9, 0xa0, 0xd3, 0xeb, 0x75,
-	0x36, 0xbb, 0x7d, 0xa5, 0xbd, 0xba, 0x9e, 0xb8, 0x2a, 0xd7, 0xa0, 0xd2, 0xdd, 0xa4, 0x1f, 0xea,
-	0x12, 0x9a, 0x02, 0xd8, 0x7c, 0xd6, 0x6d, 0x2b, 0xac, 0x5d, 0x20, 0x77, 0xe7, 0xad, 0x9d, 0x87,
-	0x4f, 0x3a, 0x6b, 0xac, 0xa3, 0xb8, 0xa4, 0xc2, 0xa5, 0x94, 0x3e, 0x16, 0x3c, 0x6f, 0xc3, 0x62,
-	0x86, 0xc2, 0x67, 0x4a, 0x27, 0x75, 0x39, 0x9f, 0x80, 0xf1, 0xee, 0x26, 0xfb, 0xc2, 0xae, 0xe7,
-	0x4c, 0x25, 0xeb, 0x28, 0xac, 0xfc, 0x75, 0x01, 0xc6, 0xba, 0x74, 0xf5, 0xe8, 0x19, 0x40, 0xf4,
-	0x58, 0x80, 0xe2, 0x05, 0xd0, 0xe4, 0x23, 0x42, 0xf3, 0x52, 0xca, 0x10, 0xdb, 0x43, 0xc7, 0x3f,
-	0x94, 0xd1, 0x17, 0x7f, 0xff, 0xd7, 0xaf, 0x0b, 0x13, 0x32, 0xb4, 0x0e, 0x56, 0x5a, 0xec, 0xa2,
-	0x8e, 0xbe, 0x90, 0x00, 0xa5, 0x1f, 0x10, 0xd0, 0x9b, 0x31, 0x0d, 0x79, 0x0f, 0x0c, 0xcd, 0xd9,
-	0x58, 0x64, 0x62, 0x09, 0x95, 0x7c, 0x97, 0xaa, 0x59, 0x92, 0xaf, 0x11, 0x35, 0xfc, 0xb2, 0xd4,
-	0x52, 0x85, 0x39, 0x5a, 0xec, 0x3d, 0xec, 0x7e, 0x78, 0x93, 0x4a, 0x82, 0xe0, 0xcf, 0x57, 0xb9,
-	0x20, 0x62, 0x4f, 0x08, 0x67, 0x05, 0xc1, 0x1e, 0xec, 0x22, 0x10, 0x9f, 0xc3, 0x4c, 0xea, 0x0d,
-	0x00, 0x2d, 0xe6, 0x41, 0x10, 0x9f, 0x08, 0xb2, 0x11, 0xb4, 0x28, 0x82, 0x3b, 0xf2, 0xd5, 0x5c,
-	0x04, 0xf4, 0xb9, 0x2d, 0x02, 0xf0, 0x0b, 0x09, 0x2e, 0x64, 0x15, 0xf9, 0xd1, 0xad, 0x3c, 0x10,
-	0x89, 0x8b, 0x6c, 0x36, 0x8e, 0x15, 0x8a, 0xe3, 0x2d, 0xf9, 0x7a, 0x2e, 0x8e, 0x80, 0xa9, 0x23,
-	0x28, 0xbf, 0x92, 0xe0, 0x52, 0x76, 0x51, 0x1e, 0xdd, 0xc9, 0x03, 0x93, 0x2a, 0xdc, 0x67, 0xc3,
-	0xf9, 0x5f, 0x0a, 0x67, 0x59, 0xbe, 0x91, 0x0b, 0x27, 0x22, 0xfe, 0x7c, 0x0b, 0xe1, 0xef, 0x6f,
-	0xb9, 0x16, 0x12, 0x2b, 0xcd, 0x9f, 0xd5, 0x42, 0x98, 0x17, 0xe5, 0x5a, 0x08, 0x7b, 0xdc, 0xcb,
-	0xb5, 0x10, 0xb1, 0xf4, 0x7e, 0x56, 0x0b, 0xa1, 0x31, 0x2e, 0x02, 0xa0, 0xc2, 0x84, 0x58, 0x3c,
-	0x47, 0xb1, 0xdb, 0x53, 0x46, 0x59, 0x3d, 0x97, 0x09, 0x1a, 0x54, 0x33, 0x92, 0xeb, 0x11, 0x13,
-	0xb4, 0x76, 0xc9, 0x78, 0xf4, 0x23, 0xa8, 0x09, 0xe5, 0xea, 0xb8, 0x86, 0x8c, 0x3a, 0x76, 0x13,
-	0xa5, 0x0a, 0x3f, 0x9e, 0x7c, 0x81, 0xce, 0x3e, 0x25, 0x57, 0xc9, 0xec, 0xb4, 0x0a, 0x74, 0x5f,
-	0x5a, 0x42, 0x1f, 0xc2, 0x64, 0xac, 0x5a, 0x8c, 0x16, 0xd2, 0xf5, 0xd2, 0xd3, 0x11, 0xd9, 0x92,
-	0x48, 0x64, 0x36, 0xcc, 0x66, 0x14, 0x91, 0xd1, 0xcd, 0xb4, 0x92, 0xac, 0xe2, 0xcb, 0x71, 0x3b,
-	0xb5, 0x44, 0x77, 0x4a, 0xbc, 0x78, 0x23, 0x33, 0xa8, 0x7e, 0x27, 0x2e, 0x02, 0xb7, 0x8e, 0x29,
-	0x03, 0x1f, 0xab, 0x72, 0x96, 0xaa, 0x9c, 0x5c, 0xaa, 0x11, 0x95, 0x1e, 0x1b, 0x8a, 0xba, 0x00,
-	0x1b, 0xd8, 0x0f, 0xfe, 0xdb, 0x21, 0x67, 0x68, 0xdc, 0xcc, 0xb8, 0x70, 0x30, 0x1f, 0xaa, 0x09,
-	0x66, 0x86, 0x9e, 0xc0, 0x78, 0x50, 0x9f, 0x45, 0xb1, 0x72, 0x57, 0xa2, 0x6a, 0xdb, 0x9c, 0x49,
-	0x66, 0x25, 0x9e, 0x5c, 0xa7, 0x13, 0x02, 0x1a, 0x27, 0x13, 0xd2, 0xda, 0x74, 0x0f, 0x6a, 0x3f,
-	0xc0, 0xaa, 0xe9, 0xef, 0x69, 0x7b, 0x58, 0xdb, 0xcf, 0x85, 0x97, 0xb7, 0x62, 0x6e, 0x30, 0x68,
-	0xa2, 0xb5, 0x27, 0xcc, 0xf2, 0x39, 0x5c, 0xcc, 0xac, 0x1b, 0xa2, 0xd8, 0xe3, 0xca, 0x51, 0xa5,
-	0xc5, 0x5c, 0x85, 0x8b, 0x54, 0xe1, 0x55, 0x79, 0x56, 0xb0, 0xff, 0x34, 0x0b, 0x6a, 0x00, 0x4f,
-	0x0c, 0x6b, 0x9f, 0x87, 0xc4, 0xfc, 0xb7, 0xf3, 0x5c, 0x35, 0x32, 0x55, 0x73, 0x59, 0x7e, 0x43,
-	0x74, 0x70, 0xd3, 0xb0, 0xf6, 0x83, 0x08, 0x28, 0x2d, 0x05, 0x4a, 0x78, 0xc8, 0xcb, 0x7f, 0xfd,
-	0x3e, 0x83, 0x12, 0x1e, 0xe1, 0xa4, 0x25, 0xf4, 0x31, 0x54, 0x89, 0x12, 0x16, 0xd3, 0x72, 0xdf,
-	0xaf, 0x73, 0x55, 0x5c, 0xa7, 0x2a, 0xe6, 0xe5, 0x4b, 0x29, 0x15, 0x2c, 0x84, 0x49, 0x4b, 0xc8,
-	0x83, 0x09, 0xb1, 0xb4, 0x1a, 0x27, 0x8e, 0x8c, 0xa2, 0x6b, 0xae, 0xae, 0x25, 0xaa, 0x6b, 0x51,
-	0x9e, 0x4b, 0xe9, 0x4a, 0x1f, 0x90, 0x0d, 0x53, 0x64, 0x6a, 0x21, 0x3a, 0x1d, 0xfd, 0x34, 0x9c,
-	0xab, 0xf4, 0x26, 0x55, 0xba, 0x20, 0xcf, 0xa7, 0x94, 0x0a, 0xc1, 0x28, 0x3a, 0x2c, 0x1e, 0x7d,
-	0xf2, 0x1f, 0x78, 0xcf, 0x70, 0x58, 0x3c, 0xd8, 0x44, 0x87, 0xc5, 0xc2, 0x4b, 0xee, 0x13, 0xed,
-	0x19, 0x0e, 0x8b, 0x45, 0x13, 0x69, 0x09, 0x75, 0xa1, 0xf6, 0xc4, 0xf0, 0xfc, 0xc0, 0x9f, 0x4e,
-	0xc4, 0x26, 0x5c, 0x38, 0xe0, 0x5e, 0x24, 0x72, 0xef, 0xa7, 0x30, 0x93, 0xaa, 0x6a, 0xc7, 0x03,
-	0x63, 0x5e, 0xd1, 0xbb, 0x79, 0x39, 0xaf, 0xcc, 0x49, 0x46, 0x04, 0xec, 0x8b, 0xd2, 0xec, 0xfb,
-	0x95, 0x04, 0x28, 0x5d, 0xce, 0x8e, 0x27, 0x04, 0xb9, 0xe5, 0xee, 0xe6, 0x95, 0xdc, 0x8a, 0x0a,
-	0x55, 0xfb, 0x88, 0xaa, 0x7d, 0x80, 0x1a, 0x02, 0x03, 0xb7, 0x3e, 0x8b, 0xae, 0x56, 0xaf, 0x9e,
-	0x2f, 0x22, 0x39, 0xef, 0x5b, 0xeb, 0x33, 0x5e, 0xd7, 0x7b, 0x85, 0x6c, 0x40, 0xe9, 0x6a, 0x68,
-	0x1c, 0x63, 0x6e, 0xb5, 0xb4, 0xd9, 0xcc, 0xc5, 0xe8, 0xc9, 0x97, 0x28, 0xc0, 0xba, 0x2c, 0x86,
-	0x08, 0x72, 0xb0, 0xcf, 0xa1, 0xa2, 0x38, 0xda, 0xa3, 0x91, 0xa5, 0xa1, 0xe9, 0x98, 0x16, 0x47,
-	0x6b, 0x26, 0x3b, 0xe4, 0xb7, 0xe9, 0x24, 0xb7, 0xe4, 0x09, 0x32, 0x89, 0xeb, 0x68, 0xad, 0xcf,
-	0x0c, 0xfd, 0xd5, 0xfd, 0xa0, 0x7a, 0xfa, 0x9c, 0xd0, 0xb1, 0xf0, 0x01, 0x0d, 0x60, 0x62, 0x87,
-	0xdc, 0xac, 0xcf, 0xc1, 0x87, 0x01, 0xed, 0xc6, 0x7c, 0x7b, 0x64, 0x25, 0x18, 0x31, 0x54, 0x74,
-	0x76, 0x4e, 0x3c, 0x4a, 0x51, 0xc4, 0x8a, 0x3a, 0xd4, 0x98, 0xa2, 0xb3, 0xf2, 0xe2, 0x0d, 0xaa,
-	0xe6, 0x8a, 0xdc, 0xc8, 0x50, 0x13, 0x32, 0xe3, 0x10, 0xa6, 0x98, 0x96, 0x90, 0x1b, 0x8f, 0x7a,
-	0x5e, 0x3a, 0x1d, 0x45, 0x71, 0x5d, 0x21, 0x33, 0x52, 0x22, 0xae, 0x33, 0x75, 0xe7, 0x67, 0xc5,
-	0xdb, 0x54, 0xa5, 0x2c, 0x5f, 0xc9, 0x50, 0x19, 0xe7, 0xc5, 0xf0, 0xc8, 0xce, 0xce, 0x8c, 0x47,
-	0x1d, 0x59, 0xc4, 0x8d, 0xe1, 0x91, 0x9d, 0x95, 0x1d, 0x8f, 0x3a, 0xb2, 0x90, 0x1f, 0x55, 0x98,
-	0x8c, 0xd5, 0x4c, 0xe3, 0xa9, 0x6a, 0x56, 0x39, 0x35, 0x57, 0x1f, 0xf7, 0xd4, 0xa6, 0x98, 0x7c,
-	0x11, 0x15, 0x3e, 0xcc, 0x66, 0x94, 0x41, 0xe2, 0xe9, 0x6a, 0x7e, 0x9d, 0xe4, 0x08, 0x02, 0x5b,
-	0xd5, 0xf6, 0xbd, 0xb8, 0xd6, 0x88, 0x1f, 0x1e, 0xfe, 0xa6, 0xf0, 0xe5, 0xea, 0x3f, 0x24, 0x34,
-	0x82, 0x49, 0x56, 0x57, 0x58, 0x58, 0xdd, 0xea, 0x2c, 0x1c, 0xac, 0xc8, 0x7d, 0xb8, 0xbe, 0xbd,
-	0x87, 0x17, 0x82, 0xce, 0x91, 0xbf, 0x67, 0xbb, 0xde, 0xc2, 0xcd, 0x85, 0x35, 0xdb, 0xf2, 0x5d,
-	0x63, 0x77, 0xe4, 0xdb, 0xae, 0x87, 0x16, 0xf7, 0x7c, 0xdf, 0xf1, 0xee, 0xb7, 0x5a, 0x03, 0xc3,
-	0xdf, 0x1b, 0xed, 0x2e, 0x6b, 0xf6, 0xb0, 0xb5, 0x87, 0x5d, 0xdb, 0xd0, 0x4c, 0x75, 0xd7, 0x6b,
-	0x31, 0x40, 0xcd, 0x0b, 0x7b, 0xd8, 0x34, 0xed, 0x07, 0xd1, 0x07, 0x22, 0xb7, 0x52, 0x5c, 0x59,
-	0xbe, 0xbb, 0x24, 0x49, 0x2b, 0x75, 0xd5, 0x71, 0x4c, 0xce, 0xe1, 0xad, 0x4f, 0x3c, 0xdb, 0xba,
-	0x9f, 0xea, 0x71, 0xef, 0xc3, 0x3c, 0x07, 0xe2, 0x61, 0xf7, 0x00, 0xbb, 0x0b, 0xba, 0xad, 0x8d,
-	0x86, 0xd8, 0x62, 0xff, 0xfc, 0x8b, 0xe6, 0x03, 0x18, 0x71, 0x15, 0x2d, 0xdd, 0xd6, 0x3c, 0x98,
-	0xd3, 0xec, 0xe1, 0xb2, 0xf0, 0x21, 0xda, 0xa5, 0x87, 0x55, 0x36, 0xe9, 0xaa, 0x63, 0x6c, 0x49,
-	0xcf, 0x8b, 0xaa, 0x63, 0x7c, 0x55, 0x28, 0x75, 0x1f, 0x6f, 0x3d, 0xfc, 0x53, 0x81, 0x17, 0x59,
-	0x76, 0xc7, 0xe8, 0xd9, 0xdd, 0xfb, 0x4f, 0x00, 0x00, 0x00, 0xff, 0xff, 0xe3, 0x44, 0xd4, 0x4a,
-	0x1d, 0x2d, 0x00, 0x00,
+	// 3354 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x5a, 0xcd, 0x73, 0x1b, 0xc7,
+	0x95, 0xdf, 0x01, 0x08, 0x02, 0x78, 0xe0, 0x07, 0xd8, 0x94, 0x64, 0x10, 0xd4, 0x07, 0x35, 0xa2,
+	0xf5, 0x41, 0xdb, 0x84, 0x16, 0xda, 0x5d, 0xbb, 0xe4, 0xdd, 0xb5, 0x28, 0x12, 0xe2, 0x62, 0x25,
+	0x81, 0xac, 0x01, 0x29, 0x25, 0xaa, 0xb2, 0xe1, 0xe6, 0x4c, 0x0b, 0x1c, 0x73, 0x30, 0x33, 0x9e,
+	0x19, 0x50, 0xa6, 0x5d, 0x2a, 0xa7, 0x7c, 0x49, 0x2a, 0x87, 0x54, 0xca, 0x39, 0xa6, 0x2a, 0x49,
+	0x39, 0x17, 0x57, 0xaa, 0x52, 0x95, 0xbf, 0x23, 0xc7, 0xe4, 0xe2, 0xca, 0x29, 0x87, 0x1c, 0xfd,
+	0x1f, 0xe4, 0x92, 0xea, 0x8f, 0x19, 0xcc, 0x27, 0x3f, 0x25, 0x9f, 0x88, 0xee, 0x79, 0xdd, 0xef,
+	0xd7, 0xaf, 0x5f, 0xff, 0x5e, 0xf7, 0x7b, 0x84, 0x49, 0x6c, 0xeb, 0x0d, 0x6c, 0xeb, 0xcb, 0xb6,
+	0x63, 0x79, 0x16, 0x02, 0x13, 0xef, 0xe1, 0x01, 0x5e, 0xc6, 0xb6, 0x5e, 0xbf, 0xd8, 0xb7, 0xac,
+	0xbe, 0x41, 0x1a, 0x4c, 0xc2, 0x34, 0x2d, 0x0f, 0x7b, 0xba, 0x65, 0xba, 0x5c, 0xb2, 0x3e, 0x2f,
+	0xbe, 0xb2, 0xd6, 0xce, 0xf0, 0x79, 0x83, 0x0c, 0x6c, 0xef, 0x40, 0x7c, 0xbc, 0x12, 0xff, 0xe8,
+	0xe9, 0x03, 0xe2, 0x7a, 0x78, 0x60, 0x0b, 0x81, 0xcb, 0x71, 0x81, 0x17, 0x0e, 0xb6, 0x6d, 0xe2,
+	0xf8, 0xb3, 0xbf, 0xcd, 0xfe, 0xa8, 0xef, 0xf4, 0x89, 0xf9, 0x8e, 0xfb, 0x02, 0xf7, 0xfb, 0xc4,
+	0x69, 0x58, 0x36, 0xd3, 0x9f, 0xc4, 0x22, 0x7f, 0x2f, 0x41, 0x71, 0x45, 0x55, 0xad, 0xa1, 0xe9,
+	0xa1, 0x45, 0x18, 0x1b, 0xba, 0xc4, 0xa9, 0x49, 0x0b, 0xd2, 0xcd, 0x4a, 0xb3, 0xba, 0x3c, 0x5a,
+	0xd0, 0xf2, 0xb6, 0x4b, 0x1c, 0x85, 0x7d, 0x45, 0x17, 0x60, 0xfc, 0x05, 0x36, 0x0c, 0xe2, 0xd5,
+	0x72, 0x0b, 0xd2, 0xcd, 0xb2, 0x22, 0x5a, 0xe8, 0x1c, 0x14, 0xc8, 0x00, 0xeb, 0x46, 0x2d, 0xcf,
+	0xba, 0x79, 0x03, 0xdd, 0x81, 0xa2, 0x46, 0xf6, 0x75, 0x95, 0xb8, 0xb5, 0xb1, 0x85, 0xfc, 0xcd,
+	0x4a, 0x73, 0x2e, 0x3c, 0xad, 0xd0, 0xbc, 0xc6, 0x24, 0x14, 0x5f, 0x12, 0xcd, 0x43, 0x59, 0x1d,
+	0xba, 0x9e, 0x35, 0xe8, 0xe9, 0x5a, 0xad, 0xc0, 0xa6, 0x2b, 0xf1, 0x8e, 0xb6, 0x86, 0xde, 0x87,
+	0xca, 0x3e, 0x71, 0xf4, 0xe7, 0x07, 0x3d, 0x6a, 0x99, 0xda, 0x38, 0x03, 0x5b, 0x5f, 0xe6, 0x56,
+	0x59, 0xf6, 0xad, 0xb2, 0xbc, 0xe5, 0x9b, 0x4d, 0x01, 0x2e, 0x4e, 0x3b, 0xe4, 0x2b, 0x30, 0x29,
+	0x74, 0xae, 0xb2, 0xf9, 0xd0, 0x14, 0xe4, 0x74, 0x8d, 0xad, 0xb8, 0xac, 0xe4, 0x74, 0x2d, 0x24,
+	0xc0, 0x41, 0x25, 0x04, 0xee, 0xc1, 0x84, 0x10, 0x68, 0xb1, 0x05, 0x06, 0xcb, 0x96, 0xc2, 0xcb,
+	0xae, 0x43, 0xc9, 0xc6, 0xae, 0xfb, 0xc2, 0x72, 0x34, 0x61, 0xa6, 0xa0, 0x2d, 0xdf, 0x80, 0x69,
+	0x31, 0xc3, 0x03, 0xac, 0x92, 0x1d, 0xcb, 0xda, 0xa3, 0x93, 0x78, 0xd6, 0x1e, 0x31, 0xfd, 0x49,
+	0x58, 0x43, 0xfe, 0x8b, 0x04, 0x33, 0x42, 0x72, 0x1d, 0x0f, 0xc8, 0x2a, 0x31, 0x3d, 0xe2, 0x50,
+	0xe3, 0xd8, 0x06, 0x3e, 0x20, 0x4e, 0x2f, 0xc0, 0x55, 0xe2, 0x1d, 0x6d, 0x8d, 0x7e, 0xdc, 0x19,
+	0x9a, 0x9a, 0x41, 0xe8, 0x47, 0xa1, 0x98, 0x77, 0xb4, 0x35, 0xf4, 0x16, 0xcc, 0x04, 0xce, 0xd4,
+	0x73, 0x89, 0x6a, 0x99, 0x9a, 0xcb, 0x76, 0x2b, 0xaf, 0x54, 0x83, 0x0f, 0x5d, 0xde, 0x8f, 0x10,
+	0x8c, 0xb9, 0xd8, 0xf0, 0x6a, 0x63, 0x6c, 0x12, 0xf6, 0x1b, 0x5d, 0x84, 0xb2, 0xab, 0xf7, 0x4d,
+	0xec, 0x0d, 0x1d, 0x22, 0xf6, 0x65, 0xd4, 0x81, 0x16, 0x61, 0xca, 0x1e, 0xee, 0x18, 0xba, 0xda,
+	0xdb, 0x23, 0x07, 0xbd, 0xa1, 0x63, 0xb0, 0xbd, 0x29, 0x2b, 0x13, 0xbc, 0xf7, 0x21, 0x39, 0xd8,
+	0x76, 0x0c, 0xf9, 0xcd, 0xc0, 0xc0, 0xeb, 0x6c, 0xc7, 0x32, 0xd6, 0xbe, 0x18, 0x98, 0xb9, 0xeb,
+	0x11, 0x3c, 0xc8, 0x90, 0x5a, 0x85, 0x99, 0x15, 0x4d, 0x7b, 0xe0, 0xe8, 0xc4, 0xd4, 0x5c, 0x85,
+	0x7c, 0x3a, 0x24, 0xae, 0x87, 0xaa, 0x90, 0xd7, 0x35, 0xb7, 0x26, 0x2d, 0xe4, 0x6f, 0x96, 0x15,
+	0xfa, 0x93, 0xe2, 0xa6, 0xae, 0x6b, 0xe2, 0x01, 0x71, 0x6b, 0x39, 0xd6, 0x3f, 0xea, 0x90, 0x7f,
+	0x27, 0xc1, 0xdc, 0xca, 0xd0, 0xdb, 0x25, 0xa6, 0xa7, 0xab, 0xd8, 0x23, 0xdc, 0x33, 0xfc, 0xd9,
+	0xee, 0x40, 0x11, 0x73, 0x20, 0xe2, 0x5c, 0xa4, 0x39, 0xb0, 0x18, 0xe2, 0x4b, 0xa2, 0x26, 0x8c,
+	0xab, 0x0e, 0xc1, 0x1e, 0x61, 0x7b, 0x90, 0xe6, 0x9e, 0xf7, 0x2d, 0xcb, 0x78, 0x82, 0x8d, 0x21,
+	0x51, 0x84, 0x24, 0x75, 0x19, 0x1f, 0x93, 0x38, 0x42, 0x41, 0x3b, 0x01, 0x51, 0x1c, 0x98, 0x93,
+	0x40, 0xf4, 0xcf, 0xd8, 0xeb, 0x82, 0xf8, 0x1b, 0x09, 0x6a, 0x61, 0x88, 0xec, 0x74, 0xf8, 0x08,
+	0x9b, 0x71, 0x84, 0xb5, 0x14, 0x84, 0x7c, 0xc4, 0x6b, 0x03, 0xf8, 0x9d, 0x04, 0xf3, 0x61, 0x80,
+	0xfe, 0xe1, 0xf3, 0x31, 0xfe, 0x67, 0x1c, 0xe3, 0x7c, 0x0a, 0xc6, 0x60, 0xd0, 0xeb, 0x82, 0x49,
+	0xe7, 0xd3, 0x07, 0xb6, 0xe5, 0xf0, 0x93, 0x77, 0xc4, 0x7c, 0x5c, 0x52, 0xfe, 0x56, 0x82, 0x4b,
+	0xe1, 0xa5, 0x8d, 0xd8, 0xc2, 0x5f, 0xdc, 0xbb, 0xf1, 0xc5, 0x5d, 0x4a, 0x59, 0x5c, 0x68, 0xd8,
+	0x0f, 0xe6, 0xc9, 0x9c, 0x04, 0x4e, 0xe4, 0xc9, 0x62, 0xc8, 0x0f, 0xe6, 0xc9, 0x8c, 0x80, 0x4e,
+	0xe4, 0xc9, 0x7c, 0xc4, 0x6b, 0x03, 0xd8, 0x82, 0xd9, 0xfb, 0x86, 0xa5, 0xee, 0x9d, 0x91, 0xf7,
+	0xf6, 0xa1, 0xbc, 0xba, 0x8b, 0x4d, 0x93, 0x18, 0x6d, 0x2d, 0x1e, 0xe6, 0x28, 0xfd, 0x7b, 0x07,
+	0x36, 0x47, 0x5c, 0x50, 0xd8, 0x6f, 0xb9, 0x05, 0x63, 0x5b, 0x07, 0x36, 0x65, 0xec, 0xea, 0xd6,
+	0x8f, 0x37, 0x5b, 0xbd, 0xed, 0x4e, 0x77, 0xb3, 0xb5, 0xda, 0x7e, 0xd0, 0x6e, 0xad, 0x55, 0xff,
+	0x0d, 0x95, 0x60, 0x4c, 0xd9, 0xd8, 0x78, 0x5c, 0x95, 0x10, 0x82, 0xa9, 0xb5, 0xb6, 0xd2, 0x5a,
+	0xdd, 0xea, 0x3d, 0x6e, 0x75, 0xbb, 0x2b, 0xeb, 0xad, 0x6a, 0x0e, 0x95, 0xa1, 0xb0, 0xae, 0x6c,
+	0x6c, 0x6f, 0x56, 0xf3, 0xf2, 0xcf, 0x73, 0x30, 0xbb, 0xca, 0x56, 0xb9, 0xee, 0x58, 0x43, 0x3b,
+	0xc0, 0x7f, 0x0f, 0xc6, 0xfb, 0xac, 0x83, 0x2d, 0xa1, 0xd2, 0xbc, 0x19, 0xb6, 0x6c, 0xca, 0x80,
+	0xe5, 0x0e, 0x79, 0xc1, 0x3a, 0x14, 0x31, 0xae, 0xfe, 0x27, 0x09, 0x4a, 0x7e, 0x27, 0x5d, 0x01,
+	0xb3, 0x1e, 0x5f, 0x13, 0xfb, 0x8d, 0x16, 0xa0, 0xa2, 0x11, 0x57, 0x75, 0x74, 0x76, 0x1f, 0x12,
+	0x01, 0x32, 0xdc, 0x85, 0xe6, 0xa0, 0x64, 0x60, 0xb3, 0xdf, 0xf3, 0x70, 0x5f, 0xd8, 0xbd, 0x48,
+	0xdb, 0x5b, 0xb8, 0x4f, 0xb7, 0x64, 0x40, 0x3c, 0xac, 0x61, 0x0f, 0x8b, 0xa8, 0x18, 0xb4, 0xd1,
+	0x25, 0x00, 0xbc, 0x8f, 0x3d, 0xec, 0xb0, 0xb8, 0x27, 0x42, 0x23, 0xef, 0xd9, 0x76, 0x0c, 0x54,
+	0x83, 0xa2, 0xed, 0xe8, 0xfb, 0xd4, 0x05, 0x68, 0x4c, 0x2c, 0x29, 0x7e, 0x53, 0x7e, 0x00, 0xe7,
+	0xd6, 0x88, 0x41, 0x3c, 0x72, 0xc6, 0xcd, 0x5c, 0x86, 0x3a, 0x9f, 0xa7, 0x63, 0x79, 0xfa, 0x73,
+	0xea, 0xb7, 0xf4, 0x92, 0x97, 0x39, 0x9b, 0xac, 0xc2, 0x79, 0x2e, 0xdf, 0xf5, 0x2c, 0x07, 0xf7,
+	0xc9, 0xc6, 0xce, 0x27, 0x44, 0xf5, 0xda, 0x1a, 0xba, 0x0c, 0xa0, 0x5a, 0x86, 0x41, 0x54, 0x66,
+	0x21, 0x6e, 0xbc, 0x50, 0x0f, 0x9d, 0x6a, 0x8f, 0x1c, 0x08, 0xd3, 0xd1, 0x9f, 0x74, 0x71, 0xfb,
+	0xc4, 0x71, 0xa9, 0xb8, 0xb0, 0x98, 0x68, 0xca, 0x1f, 0xc1, 0x7c, 0x8a, 0x92, 0x00, 0xd5, 0x07,
+	0x50, 0xb6, 0x84, 0x5a, 0x7f, 0xcf, 0xaf, 0x86, 0xf7, 0x3c, 0x15, 0xa0, 0x32, 0x1a, 0x23, 0xff,
+	0x5e, 0x82, 0x71, 0x6e, 0xb7, 0x63, 0xde, 0x5d, 0xcf, 0x41, 0xc1, 0xf5, 0xfc, 0x83, 0x58, 0x50,
+	0x78, 0x43, 0xfe, 0x10, 0x0a, 0x5d, 0xfa, 0x03, 0x9d, 0x87, 0x99, 0xee, 0xd6, 0xca, 0x56, 0xdc,
+	0xb3, 0x01, 0xc6, 0x1f, 0x28, 0xed, 0x56, 0x67, 0xad, 0x2a, 0xa1, 0x69, 0xa8, 0xb4, 0x3b, 0x4f,
+	0xda, 0x5b, 0xad, 0x5e, 0xb7, 0xd5, 0xd9, 0xaa, 0xe6, 0xd0, 0x2c, 0x4c, 0x8b, 0x0e, 0xa5, 0xb5,
+	0xda, 0x6a, 0x3f, 0x69, 0xad, 0x55, 0xf3, 0xa8, 0x02, 0xc5, 0xfb, 0x8f, 0x36, 0x56, 0x1f, 0xb6,
+	0xd6, 0xaa, 0x63, 0xf2, 0xbb, 0x50, 0x14, 0x9b, 0x8b, 0xde, 0x86, 0xe2, 0x73, 0xfe, 0x53, 0xac,
+	0x17, 0x85, 0x81, 0x72, 0x29, 0xc5, 0x17, 0x91, 0x35, 0x98, 0x5e, 0x27, 0x1e, 0x85, 0x7f, 0x5a,
+	0xb7, 0x40, 0x57, 0x61, 0xe2, 0xb9, 0x08, 0x59, 0x3d, 0x9d, 0xdd, 0xf6, 0xa8, 0x40, 0xc5, 0xef,
+	0xa3, 0x46, 0xfc, 0x3e, 0x07, 0x05, 0x7e, 0x62, 0xe2, 0x1c, 0x70, 0x09, 0x80, 0xb1, 0x91, 0xe5,
+	0x8c, 0x6e, 0x93, 0x65, 0xd1, 0xd3, 0xd6, 0x82, 0x03, 0x96, 0xcf, 0x3e, 0x60, 0x63, 0x87, 0x1f,
+	0xb0, 0x42, 0xf6, 0x01, 0x1b, 0x3f, 0xf4, 0x80, 0x15, 0x0f, 0x39, 0x60, 0xa5, 0xc8, 0x01, 0xa3,
+	0x5b, 0xce, 0xe9, 0xba, 0xcc, 0xb7, 0x9c, 0x53, 0xf2, 0xfb, 0x50, 0xe1, 0x44, 0xcb, 0x1f, 0x11,
+	0x70, 0xf4, 0x23, 0x82, 0x8b, 0xd3, 0x0e, 0x3a, 0x78, 0x68, 0x6b, 0xc1, 0xe0, 0xca, 0xd1, 0x83,
+	0xb9, 0x38, 0x7b, 0x81, 0xdc, 0x81, 0x71, 0xce, 0x62, 0xe8, 0x56, 0x8c, 0xef, 0x66, 0xc2, 0xbe,
+	0x10, 0x21, 0x36, 0xf9, 0xa7, 0x12, 0x5c, 0x6c, 0xb3, 0x58, 0xef, 0x5f, 0x40, 0x62, 0x74, 0x71,
+	0xca, 0xcb, 0xcb, 0x6d, 0x28, 0x38, 0xc4, 0x15, 0x4f, 0xb9, 0xc3, 0x03, 0x13, 0x17, 0x94, 0x7f,
+	0x22, 0xc1, 0xec, 0x23, 0xdd, 0xdc, 0x7b, 0x75, 0xb7, 0xa7, 0x13, 0xdf, 0x76, 0xfe, 0x98, 0x03,
+	0xf4, 0x48, 0x77, 0xbd, 0xc7, 0xd8, 0x53, 0x77, 0x49, 0x60, 0x82, 0x7f, 0x87, 0x82, 0xa1, 0x0f,
+	0xf4, 0x91, 0xfe, 0xf8, 0x4c, 0x6d, 0xd3, 0xbb, 0xd3, 0x14, 0x8b, 0x61, 0x92, 0xe8, 0x1e, 0x4c,
+	0xe2, 0xa1, 0xb7, 0x6b, 0x39, 0x3a, 0x7d, 0x14, 0xef, 0x1f, 0x27, 0x3e, 0x47, 0x07, 0xa0, 0x26,
+	0x14, 0x0c, 0xbc, 0x43, 0xf8, 0xa3, 0xb7, 0xd2, 0xbc, 0x98, 0x18, 0xd9, 0xf5, 0x1c, 0xdd, 0xec,
+	0xfb, 0x5a, 0xa9, 0x28, 0xfa, 0x2f, 0x28, 0x0d, 0x74, 0xb3, 0xe7, 0xea, 0x9f, 0x13, 0xb1, 0xea,
+	0x43, 0xb1, 0x16, 0x07, 0xba, 0xd9, 0xd5, 0x3f, 0x27, 0x6c, 0x1c, 0xfe, 0x8c, 0x8f, 0x2b, 0x1c,
+	0x67, 0x1c, 0xfe, 0x8c, 0x8e, 0x93, 0x3f, 0x83, 0x1a, 0x35, 0x57, 0x6a, 0x60, 0x38, 0x85, 0xd1,
+	0x6e, 0x41, 0x55, 0xc5, 0xea, 0x2e, 0xc1, 0x3b, 0x06, 0xe9, 0xa9, 0x43, 0xc7, 0xb5, 0x1c, 0xc1,
+	0x0d, 0xd3, 0x41, 0xff, 0x2a, 0xeb, 0x96, 0x7f, 0x2b, 0xc1, 0x1c, 0x55, 0x9d, 0x4e, 0xff, 0x6f,
+	0x40, 0x91, 0x12, 0xd5, 0xe8, 0x19, 0x3b, 0x4e, 0x9b, 0x89, 0x10, 0x94, 0x4b, 0x84, 0xa0, 0x00,
+	0x74, 0xfe, 0xd8, 0xa0, 0x2f, 0xc0, 0xb8, 0x80, 0xca, 0x29, 0x49, 0xb4, 0xe4, 0x5f, 0x4a, 0x50,
+	0x60, 0x7e, 0x44, 0x79, 0x69, 0x40, 0x7f, 0x8c, 0xe0, 0x14, 0x59, 0xbb, 0x4d, 0x63, 0x4b, 0x8a,
+	0x9b, 0x94, 0x5e, 0x85, 0x2b, 0xd0, 0x47, 0xb6, 0xef, 0x06, 0x05, 0x85, 0xfd, 0x96, 0xdf, 0x83,
+	0x32, 0x43, 0x44, 0x0d, 0x87, 0xde, 0x02, 0x8e, 0x82, 0xa4, 0x92, 0x04, 0x93, 0x53, 0x7c, 0x09,
+	0xf9, 0xef, 0x12, 0x4c, 0x84, 0x77, 0x39, 0x41, 0xe8, 0x35, 0x28, 0xba, 0x43, 0xb6, 0x09, 0xc2,
+	0xaa, 0x7e, 0x93, 0x7e, 0x51, 0x2d, 0xd3, 0x23, 0xa6, 0xe7, 0xc7, 0x70, 0xd1, 0xa4, 0x10, 0x55,
+	0x4b, 0x0b, 0x20, 0xd2, 0xdf, 0x68, 0x1e, 0xca, 0x2e, 0x31, 0x35, 0xbe, 0x77, 0x22, 0x3f, 0xc3,
+	0x3b, 0x78, 0x7e, 0x26, 0x4c, 0xad, 0xe3, 0x27, 0xa2, 0xd6, 0xcb, 0x00, 0x36, 0xbd, 0x3c, 0xb8,
+	0x0c, 0x4a, 0x91, 0xd9, 0x39, 0xd4, 0x23, 0xbf, 0x84, 0x6a, 0x78, 0x85, 0xcc, 0x46, 0xff, 0x0b,
+	0x93, 0x66, 0xd8, 0xb7, 0x85, 0xa5, 0x22, 0x17, 0xf3, 0xf0, 0x20, 0x25, 0x2a, 0x7e, 0x12, 0x87,
+	0xfe, 0x18, 0x66, 0x15, 0x82, 0xb5, 0xb3, 0xdf, 0x99, 0x42, 0xbe, 0x9f, 0x0f, 0xfb, 0xbe, 0xfc,
+	0x0c, 0xe6, 0x12, 0x1a, 0x82, 0x13, 0xf3, 0x3f, 0xc9, 0x0b, 0xd3, 0x95, 0xf0, 0x2a, 0x53, 0xb0,
+	0x85, 0xaf, 0x4b, 0xff, 0x0f, 0x79, 0xc5, 0x56, 0xd3, 0xbc, 0xc2, 0xc6, 0x07, 0x86, 0x85, 0xfd,
+	0x18, 0xef, 0x37, 0xe9, 0x99, 0xd8, 0xf5, 0x3c, 0xbb, 0x47, 0xc1, 0x0b, 0xb7, 0xa0, 0xed, 0x87,
+	0xe4, 0x40, 0xfe, 0x6f, 0x28, 0x76, 0x89, 0x4b, 0x6f, 0x79, 0xe9, 0xa9, 0x19, 0xea, 0x23, 0x43,
+	0xcd, 0xee, 0xf1, 0x2f, 0x22, 0x13, 0x35, 0xd4, 0xec, 0x2d, 0x96, 0xb7, 0xf9, 0x2e, 0x07, 0x93,
+	0x11, 0xa0, 0xaf, 0xd0, 0x84, 0x14, 0xcf, 0x3e, 0x3d, 0x64, 0xe2, 0xa8, 0xf3, 0x46, 0xf8, 0x96,
+	0x5a, 0x88, 0xdc, 0x52, 0xd1, 0x0d, 0x98, 0xb6, 0x89, 0x33, 0xd0, 0xd9, 0x6a, 0x7a, 0x0e, 0xc1,
+	0x1a, 0x73, 0xda, 0x82, 0x32, 0x35, 0xea, 0xa6, 0x96, 0xa5, 0x8e, 0x12, 0x12, 0x7c, 0xe1, 0xe8,
+	0x1e, 0x61, 0x2e, 0x5a, 0x50, 0x42, 0x13, 0x3c, 0xa5, 0xdd, 0xf1, 0x43, 0x50, 0x3a, 0xcb, 0xfd,
+	0xa2, 0x7c, 0xa2, 0xfb, 0xc5, 0x47, 0x50, 0x8d, 0x58, 0x76, 0x45, 0xdd, 0x7b, 0xa5, 0x77, 0xfa,
+	0x16, 0xcc, 0xc4, 0xe7, 0x77, 0xd1, 0x6d, 0x18, 0xc3, 0xea, 0x9e, 0xef, 0x93, 0x17, 0xc3, 0x3e,
+	0x19, 0x17, 0x56, 0x98, 0xa4, 0xdc, 0x82, 0xa9, 0xa8, 0x8f, 0xd3, 0xb7, 0x3f, 0x77, 0x55, 0x7f,
+	0x9a, 0xb9, 0xcc, 0x69, 0x14, 0x5f, 0x52, 0xfe, 0x38, 0x86, 0x86, 0x11, 0xc2, 0x69, 0x66, 0x0a,
+	0x45, 0x88, 0x5c, 0x24, 0x42, 0xfc, 0x33, 0x07, 0xe7, 0xb6, 0x99, 0x79, 0xc5, 0x25, 0xc6, 0x3f,
+	0x8c, 0xef, 0x85, 0x5e, 0xe8, 0xd2, 0x31, 0x28, 0x7f, 0x94, 0xe2, 0xf9, 0x00, 0x26, 0x34, 0xdd,
+	0xb5, 0x0d, 0x7c, 0xd0, 0x63, 0xa3, 0x73, 0xc7, 0x18, 0x5d, 0x11, 0x23, 0x3a, 0x98, 0x39, 0x48,
+	0xf8, 0x32, 0x7c, 0x9c, 0x78, 0x13, 0xba, 0x2a, 0xbf, 0x1b, 0xba, 0x80, 0x8f, 0x1d, 0x63, 0x68,
+	0x70, 0x3d, 0x7f, 0x0f, 0x4a, 0x86, 0xc5, 0x49, 0x53, 0xdc, 0x3f, 0x8e, 0x58, 0xb0, 0x2f, 0x4d,
+	0x47, 0x52, 0x4f, 0xfe, 0xdc, 0x32, 0xfd, 0x78, 0x70, 0xc4, 0x48, 0x5f, 0x5a, 0xfe, 0x7a, 0x0c,
+	0xc6, 0xe8, 0x03, 0x28, 0x41, 0x5a, 0xe1, 0xfc, 0x48, 0x2e, 0x96, 0x42, 0xbb, 0x1a, 0xb3, 0x6f,
+	0x5e, 0xbc, 0x42, 0x42, 0x16, 0x8c, 0x3e, 0x27, 0xc6, 0xe2, 0xcf, 0x89, 0xc3, 0x1f, 0x29, 0x81,
+	0x15, 0xc4, 0x23, 0x25, 0x58, 0x67, 0x3d, 0xb4, 0x4e, 0xfe, 0x44, 0x09, 0xda, 0x91, 0xc7, 0x4d,
+	0x29, 0xf6, 0xb8, 0xb9, 0x02, 0x95, 0xd0, 0x2b, 0x8d, 0x1d, 0xf8, 0xb2, 0x02, 0xa3, 0x47, 0x1a,
+	0x25, 0x53, 0x6e, 0x2f, 0xfa, 0x19, 0xf8, 0x68, 0xde, 0xd1, 0xd6, 0xd0, 0x35, 0x98, 0xec, 0xe3,
+	0x01, 0x51, 0x59, 0xe6, 0x8e, 0x0a, 0x54, 0x78, 0xda, 0x7d, 0xd4, 0xd9, 0x66, 0x54, 0xee, 0x7a,
+	0x04, 0xb3, 0x8a, 0xca, 0x84, 0x88, 0xfd, 0xb4, 0xdd, 0xd6, 0xa8, 0xe7, 0x5b, 0xa6, 0xa1, 0x9b,
+	0xa4, 0x36, 0xc9, 0xe2, 0xad, 0x68, 0x51, 0x1b, 0x11, 0xad, 0x4f, 0x7a, 0xfc, 0x56, 0x3f, 0xc5,
+	0x88, 0xae, 0x4c, 0x7b, 0x56, 0xd3, 0x9e, 0x50, 0xd3, 0x67, 0xa1, 0xb8, 0xea, 0x89, 0x28, 0xae,
+	0x01, 0x05, 0xf6, 0x28, 0x46, 0xd7, 0xa1, 0x40, 0x37, 0xdd, 0x3f, 0xe6, 0xc9, 0x57, 0x3f, 0xff,
+	0x2c, 0xff, 0x59, 0x02, 0xc4, 0x78, 0xf9, 0xac, 0x31, 0x27, 0x08, 0x2d, 0xf9, 0x8c, 0xd0, 0x32,
+	0x76, 0x64, 0x68, 0x29, 0x1c, 0x3b, 0xb4, 0x8c, 0xa7, 0x86, 0x16, 0xf9, 0x09, 0xd4, 0x93, 0x6b,
+	0x71, 0x47, 0xac, 0x14, 0xe3, 0xbe, 0xcb, 0x61, 0xa3, 0x24, 0x07, 0x06, 0x04, 0xb8, 0x64, 0xc2,
+	0x79, 0xf1, 0x65, 0x33, 0x8a, 0xed, 0x06, 0x5c, 0xeb, 0x6e, 0x6d, 0x28, 0x2b, 0xeb, 0xad, 0xde,
+	0x66, 0x4b, 0x79, 0xdc, 0xee, 0x76, 0xdb, 0x1b, 0x9d, 0x9e, 0xd2, 0x5a, 0x59, 0x8b, 0xe5, 0x49,
+	0x2a, 0x50, 0xec, 0x6c, 0xb0, 0x0f, 0x55, 0x09, 0x4d, 0x01, 0x6c, 0x3c, 0xed, 0xb4, 0x14, 0xde,
+	0xce, 0xa1, 0x69, 0xa8, 0x6c, 0x6e, 0xdf, 0x7f, 0xd4, 0x5e, 0xe5, 0x1d, 0xf9, 0x25, 0x0c, 0x17,
+	0x12, 0xfa, 0x78, 0xf0, 0xbc, 0x09, 0x8b, 0x29, 0x0a, 0x9f, 0x2a, 0xed, 0x44, 0x66, 0x66, 0x02,
+	0x4a, 0x9d, 0x0d, 0xfe, 0x85, 0xe7, 0x66, 0xb8, 0x4a, 0xde, 0x91, 0x6b, 0x7e, 0x7b, 0x15, 0xc6,
+	0x3b, 0x6c, 0xf5, 0xe8, 0x29, 0xc0, 0xa8, 0x52, 0x84, 0xa2, 0xd9, 0xef, 0x78, 0x05, 0xa9, 0x7e,
+	0x21, 0xe1, 0x88, 0xad, 0x81, 0xed, 0x1d, 0xc8, 0xe8, 0xab, 0xbf, 0xfe, 0xe3, 0x57, 0xb9, 0x09,
+	0x19, 0x1a, 0xfb, 0xcd, 0x06, 0xcf, 0xd2, 0xa0, 0xaf, 0x24, 0x40, 0xc9, 0xea, 0x11, 0x7a, 0x33,
+	0xa2, 0x21, 0xab, 0xba, 0x54, 0x9f, 0x8d, 0x44, 0x26, 0x7e, 0xa1, 0x92, 0x6f, 0x33, 0x35, 0x4b,
+	0xf2, 0x15, 0xaa, 0x46, 0xbc, 0x94, 0x1b, 0x38, 0x34, 0x47, 0x83, 0x17, 0x43, 0xef, 0x06, 0xcf,
+	0xe8, 0x38, 0x08, 0x51, 0xbb, 0xcc, 0x04, 0x11, 0xa9, 0x1f, 0x9d, 0x16, 0x04, 0xaf, 0xd6, 0x8e,
+	0x40, 0x7c, 0x09, 0x33, 0x89, 0x02, 0x10, 0x5a, 0xcc, 0x82, 0x10, 0xae, 0x0f, 0xa5, 0x23, 0x68,
+	0x30, 0x04, 0xb7, 0xe4, 0xcb, 0x99, 0x08, 0x58, 0xad, 0x75, 0x04, 0xe0, 0x67, 0x12, 0x9c, 0x4b,
+	0xab, 0xf0, 0xa0, 0x1b, 0x59, 0x20, 0x62, 0x59, 0x8c, 0x74, 0x1c, 0x4d, 0x86, 0xe3, 0x6d, 0xf9,
+	0x6a, 0x26, 0x0e, 0x9f, 0xa9, 0x47, 0x50, 0x7e, 0x21, 0xc1, 0x85, 0xf4, 0x8a, 0x0c, 0xba, 0x95,
+	0x05, 0x26, 0x51, 0xb5, 0x49, 0x87, 0xf3, 0x1f, 0x0c, 0xce, 0xb2, 0x7c, 0x2d, 0x13, 0xce, 0x88,
+	0xf8, 0xb3, 0x3d, 0x44, 0x14, 0x5f, 0x33, 0x3d, 0x24, 0x52, 0x97, 0x39, 0xad, 0x87, 0xf0, 0x53,
+	0x94, 0xe9, 0x21, 0xbc, 0xb2, 0x9b, 0xe9, 0x21, 0xe1, 0xba, 0xcb, 0x69, 0x3d, 0x84, 0xc5, 0xb8,
+	0x11, 0x00, 0x0c, 0x13, 0xe1, 0xca, 0x09, 0x8a, 0xbc, 0x9e, 0x52, 0x6a, 0x2a, 0x99, 0x4c, 0x50,
+	0x63, 0x9a, 0x91, 0x5c, 0x1d, 0x31, 0x41, 0x63, 0x87, 0x8e, 0x47, 0x3f, 0x82, 0x4a, 0xa8, 0x56,
+	0x11, 0xd5, 0x90, 0x52, 0xc4, 0xa8, 0xa3, 0x44, 0xd6, 0xcf, 0x95, 0xcf, 0xb1, 0xd9, 0xa7, 0xe4,
+	0x32, 0x9d, 0x9d, 0xa5, 0x00, 0xef, 0x4a, 0x4b, 0xe8, 0x43, 0x98, 0x8c, 0x94, 0x0a, 0xd0, 0x42,
+	0x32, 0x59, 0x7e, 0x32, 0x22, 0x5b, 0x0a, 0x13, 0x99, 0x05, 0xb3, 0x29, 0x15, 0x04, 0x74, 0x3d,
+	0xa9, 0x24, 0x2d, 0x93, 0x74, 0x94, 0xa5, 0x96, 0x98, 0xa5, 0xc2, 0x0f, 0x6f, 0x64, 0xf8, 0xa5,
+	0x8f, 0xd8, 0x43, 0xe0, 0xc6, 0x11, 0x35, 0x80, 0x23, 0x55, 0xce, 0x32, 0x95, 0x93, 0x4b, 0x15,
+	0xaa, 0xd2, 0xe5, 0x43, 0x51, 0x07, 0x60, 0x9d, 0x78, 0xfe, 0xbf, 0xba, 0x64, 0x0c, 0x8d, 0xba,
+	0x99, 0x10, 0xf6, 0xe7, 0x43, 0x95, 0x90, 0x9b, 0xa1, 0x47, 0x50, 0xf2, 0x93, 0xf3, 0x28, 0x92,
+	0xeb, 0x8c, 0xa5, 0xec, 0xeb, 0x33, 0xf1, 0x5b, 0x89, 0x2b, 0x57, 0xd9, 0x84, 0x80, 0x4a, 0x74,
+	0x42, 0x56, 0x98, 0xe8, 0x42, 0xe5, 0xff, 0x08, 0x36, 0xbc, 0x5d, 0x75, 0x97, 0xa8, 0x7b, 0x99,
+	0xf0, 0xb2, 0x56, 0x2c, 0x1c, 0x06, 0x4d, 0x34, 0x76, 0x43, 0xb3, 0x7c, 0x09, 0xe7, 0x53, 0x93,
+	0xc6, 0x28, 0x52, 0x59, 0x3b, 0x2c, 0xaf, 0x9c, 0xa9, 0x70, 0x91, 0x29, 0xbc, 0x2c, 0xcf, 0x86,
+	0xfc, 0x3f, 0xc9, 0x82, 0x2a, 0xc0, 0x23, 0xdd, 0xdc, 0x13, 0x21, 0x31, 0xfb, 0x1f, 0x27, 0x32,
+	0xd5, 0xc8, 0x4c, 0xcd, 0x45, 0xf9, 0x8d, 0xf0, 0x01, 0x37, 0x74, 0x73, 0xcf, 0x8f, 0x80, 0xd2,
+	0x92, 0xaf, 0x44, 0x84, 0xbc, 0xec, 0x7f, 0x7d, 0x38, 0x85, 0x12, 0x11, 0xe1, 0xa4, 0x25, 0xf4,
+	0x31, 0x94, 0xa9, 0x12, 0x1e, 0xd3, 0x32, 0xff, 0x79, 0x21, 0x53, 0xc5, 0x55, 0xa6, 0x62, 0x5e,
+	0xbe, 0x90, 0x50, 0xc1, 0x43, 0x98, 0xb4, 0x84, 0x5c, 0x98, 0x08, 0xe7, 0xd5, 0xa3, 0xc4, 0x91,
+	0x92, 0x71, 0xcf, 0xd4, 0xb5, 0xc4, 0x74, 0x2d, 0xca, 0x73, 0x09, 0x5d, 0xc9, 0x0d, 0xb2, 0x60,
+	0x8a, 0x4e, 0x1d, 0x8a, 0x4e, 0x87, 0xff, 0x5f, 0x40, 0xa6, 0xd2, 0xeb, 0x4c, 0xe9, 0x82, 0x3c,
+	0x9f, 0x50, 0x1a, 0x0a, 0x46, 0xa3, 0xcd, 0x12, 0xd1, 0x27, 0xbb, 0xba, 0x7f, 0x8a, 0xcd, 0x12,
+	0xc1, 0x66, 0xb4, 0x59, 0x3c, 0xbc, 0x64, 0xd6, 0xe7, 0x4f, 0xb1, 0x59, 0x3c, 0x9a, 0x48, 0x4b,
+	0xa8, 0x03, 0x95, 0x47, 0xba, 0xeb, 0xf9, 0xe7, 0xe9, 0x58, 0x6c, 0x22, 0x84, 0x7d, 0xee, 0x45,
+	0x61, 0xee, 0x7d, 0xca, 0xe7, 0x13, 0x15, 0x0d, 0x74, 0x39, 0xba, 0xf7, 0xf1, 0x52, 0x47, 0xfd,
+	0x7c, 0x22, 0x09, 0x4c, 0x85, 0xe4, 0x19, 0x36, 0x73, 0x05, 0xb1, 0xb0, 0xc1, 0x92, 0xc2, 0xe8,
+	0x53, 0x98, 0x49, 0xe4, 0xfe, 0xa3, 0x11, 0x37, 0xab, 0x34, 0x50, 0xbf, 0x98, 0x95, 0x3f, 0x65,
+	0xba, 0x04, 0xad, 0xa3, 0x24, 0xad, 0x7f, 0x23, 0xf1, 0xf2, 0x4c, 0x8c, 0xd5, 0xdf, 0x8c, 0x2b,
+	0x4d, 0xe7, 0xf4, 0x4b, 0x99, 0xa9, 0x1a, 0xa6, 0xf6, 0x01, 0x53, 0x7b, 0x0f, 0xd5, 0x42, 0xd4,
+	0xde, 0xf8, 0x62, 0xf4, 0x66, 0x7b, 0xf9, 0x6c, 0x11, 0xc9, 0x59, 0xdf, 0x1a, 0x5f, 0x88, 0x84,
+	0xe1, 0x4b, 0x64, 0x01, 0x4a, 0xa6, 0x59, 0xa3, 0x18, 0x33, 0xd3, 0xb0, 0xf5, 0x7a, 0x26, 0x46,
+	0x57, 0xbe, 0xc0, 0x00, 0x56, 0xe5, 0x70, 0xec, 0xa1, 0x1e, 0xf3, 0x0c, 0x8a, 0x8a, 0xad, 0x3e,
+	0x18, 0x9a, 0x2a, 0x9a, 0x8e, 0x68, 0xb1, 0xd5, 0x7a, 0xbc, 0x43, 0x7e, 0x87, 0x4d, 0x72, 0x43,
+	0x9e, 0xa0, 0x93, 0x38, 0xb6, 0xda, 0xf8, 0x42, 0xd7, 0x5e, 0xde, 0xf5, 0xd3, 0xb2, 0xcf, 0x28,
+	0xcf, 0x87, 0x3e, 0xa0, 0x3e, 0x4c, 0x6c, 0xd3, 0x27, 0xfb, 0x19, 0x88, 0xd6, 0xe7, 0xf3, 0x08,
+	0x69, 0x0c, 0xcd, 0x18, 0xd5, 0x06, 0x8a, 0x4e, 0x4f, 0xb6, 0x87, 0x29, 0x1a, 0xd1, 0xad, 0x06,
+	0x15, 0xae, 0xe8, 0xb4, 0x84, 0x7b, 0x8d, 0xa9, 0xb9, 0x24, 0xd7, 0x52, 0xd4, 0x04, 0x94, 0x3b,
+	0x80, 0x29, 0xae, 0x25, 0x20, 0xdd, 0xc3, 0x8a, 0x96, 0x27, 0xe3, 0x3e, 0xa1, 0x2b, 0xa0, 0x5c,
+	0xc6, 0xf0, 0x55, 0xae, 0xee, 0xec, 0x74, 0x7b, 0x93, 0xa9, 0x94, 0xe5, 0x4b, 0x29, 0x2a, 0xa3,
+	0x84, 0x1b, 0x6c, 0xd9, 0xe9, 0x29, 0xf7, 0xb0, 0x2d, 0x1b, 0x91, 0x6e, 0xb0, 0x65, 0xa7, 0xa5,
+	0xdd, 0xc3, 0xb6, 0x2c, 0x20, 0x5e, 0x0c, 0x93, 0x91, 0x64, 0x6c, 0xf4, 0x0e, 0x9c, 0x96, 0xa7,
+	0xcd, 0xd4, 0x27, 0x4e, 0x6a, 0x3d, 0x7c, 0xab, 0xa3, 0x2a, 0x3c, 0x98, 0x4d, 0xc9, 0xaf, 0x44,
+	0xef, 0xc1, 0xd9, 0x09, 0x98, 0x43, 0x08, 0x6c, 0x45, 0xdd, 0x73, 0xa3, 0x5a, 0x47, 0xfc, 0x70,
+	0xff, 0xd7, 0xb9, 0xaf, 0x57, 0xfe, 0x26, 0xa1, 0x21, 0x4c, 0xf2, 0x84, 0xc5, 0xc2, 0xca, 0x66,
+	0x7b, 0x61, 0xbf, 0x29, 0xf7, 0xe0, 0xea, 0xd6, 0x2e, 0x59, 0xf0, 0x3b, 0x59, 0xc5, 0xd1, 0x5d,
+	0xb8, 0xbe, 0xb0, 0x6a, 0x99, 0x9e, 0xa3, 0xef, 0x0c, 0x3d, 0xcb, 0x71, 0xd1, 0xe2, 0xae, 0xe7,
+	0xd9, 0xee, 0xdd, 0x46, 0xa3, 0xaf, 0x7b, 0xbb, 0xc3, 0x9d, 0x65, 0xd5, 0x1a, 0x34, 0x76, 0x89,
+	0x63, 0xe9, 0xaa, 0x81, 0x77, 0xdc, 0x06, 0x07, 0x54, 0x3f, 0xb7, 0x4b, 0x0c, 0xc3, 0xba, 0x37,
+	0xfa, 0x40, 0xe5, 0x9a, 0xf9, 0xe6, 0xf2, 0xed, 0x25, 0x49, 0x6a, 0x56, 0xb1, 0x6d, 0x1b, 0x82,
+	0xc3, 0x1b, 0x9f, 0xb8, 0x96, 0x79, 0x37, 0xd1, 0xe3, 0xdc, 0x85, 0x79, 0x01, 0xc4, 0x25, 0xce,
+	0x3e, 0x71, 0x16, 0x34, 0x4b, 0x1d, 0x0e, 0x88, 0xc9, 0xff, 0xa5, 0x1c, 0xcd, 0xfb, 0x30, 0xa2,
+	0x2a, 0x1a, 0x9a, 0xa5, 0xba, 0x30, 0xa7, 0x5a, 0x83, 0xe5, 0xd0, 0x87, 0x91, 0x95, 0xee, 0x97,
+	0xf9, 0xa4, 0x2b, 0xb6, 0xbe, 0x29, 0x3d, 0xcb, 0x63, 0x5b, 0xff, 0x26, 0x37, 0xd6, 0x79, 0xb8,
+	0x79, 0xff, 0x0f, 0x39, 0x91, 0xbd, 0xd9, 0x19, 0x67, 0x7b, 0x77, 0xe7, 0x5f, 0x01, 0x00, 0x00,
+	0xff, 0xff, 0xda, 0xc5, 0xa7, 0xce, 0x73, 0x2f, 0x00, 0x00,
 }
diff --git a/api/api.pb.gw.go b/api/api.pb.gw.go
index dade3ab82..5de5234e8 100644
--- a/api/api.pb.gw.go
+++ b/api/api.pb.gw.go
@@ -470,6 +470,23 @@ func request_Nakama_ListFriends_0(ctx context.Context, marshaler runtime.Marshal
 
 }
 
+var (
+	filter_Nakama_ListMatches_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
+)
+
+func request_Nakama_ListMatches_0(ctx context.Context, marshaler runtime.Marshaler, client NakamaClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
+	var protoReq ListMatchesRequest
+	var metadata runtime.ServerMetadata
+
+	if err := runtime.PopulateQueryParameters(&protoReq, req.URL.Query(), filter_Nakama_ListMatches_0); err != nil {
+		return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
+	}
+
+	msg, err := client.ListMatches(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
+	return msg, metadata, err
+
+}
+
 var (
 	filter_Nakama_ListNotifications_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
 )
@@ -1557,6 +1574,35 @@ func RegisterNakamaHandlerClient(ctx context.Context, mux *runtime.ServeMux, cli
 
 	})
 
+	mux.Handle("GET", pattern_Nakama_ListMatches_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
+		ctx, cancel := context.WithCancel(req.Context())
+		defer cancel()
+		if cn, ok := w.(http.CloseNotifier); ok {
+			go func(done <-chan struct{}, closed <-chan bool) {
+				select {
+				case <-done:
+				case <-closed:
+					cancel()
+				}
+			}(ctx.Done(), cn.CloseNotify())
+		}
+		inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
+		rctx, err := runtime.AnnotateContext(ctx, mux, req)
+		if err != nil {
+			runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+			return
+		}
+		resp, md, err := request_Nakama_ListMatches_0(rctx, inboundMarshaler, client, req, pathParams)
+		ctx = runtime.NewServerMetadataContext(ctx, md)
+		if err != nil {
+			runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
+			return
+		}
+
+		forward_Nakama_ListMatches_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
+
+	})
+
 	mux.Handle("GET", pattern_Nakama_ListNotifications_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
 		ctx, cancel := context.WithCancel(req.Context())
 		defer cancel()
@@ -2046,6 +2092,8 @@ var (
 
 	pattern_Nakama_ListFriends_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v2", "friend"}, ""))
 
+	pattern_Nakama_ListMatches_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v2", "match"}, ""))
+
 	pattern_Nakama_ListNotifications_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v2", "notification"}, ""))
 
 	pattern_Nakama_ListStorageObjects_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v2", "storage", "collection"}, ""))
@@ -2128,6 +2176,8 @@ var (
 
 	forward_Nakama_ListFriends_0 = runtime.ForwardResponseMessage
 
+	forward_Nakama_ListMatches_0 = runtime.ForwardResponseMessage
+
 	forward_Nakama_ListNotifications_0 = runtime.ForwardResponseMessage
 
 	forward_Nakama_ListStorageObjects_0 = runtime.ForwardResponseMessage
diff --git a/api/api.proto b/api/api.proto
index 9b7cdfbdc..865e78475 100644
--- a/api/api.proto
+++ b/api/api.proto
@@ -260,6 +260,11 @@ service Nakama {
     option (google.api.http).get = "/v2/friend";
   }
 
+  // Fetch list of running matches.
+  rpc ListMatches (ListMatchesRequest) returns (MatchList) {
+    option (google.api.http).get = "/v2/match";
+  }
+
   // Fetch list of notifications.
   rpc ListNotifications (ListNotificationsRequest) returns (NotificationList) {
     option (google.api.http).get = "/v2/notification";
@@ -685,6 +690,19 @@ message LinkFacebookRequest {
   google.protobuf.BoolValue import = 4;
 }
 
+message ListMatchesRequest {
+  // Limit the number of returned matches.
+  google.protobuf.Int32Value limit = 1;
+  // Authoritative or relayed matches.
+  google.protobuf.BoolValue authoritative = 2;
+  // Label filter.
+  google.protobuf.StringValue label = 3;
+  // Minimum user count.
+  google.protobuf.Int32Value min_size = 4;
+  // Maximum user count.
+  google.protobuf.Int32Value max_size = 5;
+}
+
 // Get a list of unexpired notifications.
 message ListNotificationsRequest {
   // The number of notifications to get. Between 1 and 100.
@@ -705,6 +723,24 @@ message ListStorageObjectsRequest {
   string cursor = 4; // value from StorageObjectList.cursor.
 }
 
+// Represents a realtime match.
+message Match {
+  // The ID of the match, can be used to join.
+  string match_id = 1;
+  // True if it's an server-managed authoritative match, false otherwise.
+  bool authoritative = 2;
+  // Match label, if any.
+  google.protobuf.StringValue label = 3;
+  // Current number of users in the match.
+  int32 size = 4;
+}
+
+// A list of realtime matches.
+message MatchList {
+  // A number of matches corresponding to a list operation.
+  repeated Match matches = 1;
+}
+
 // A notification in the server.
 message Notification {
   // ID of the Notification.
diff --git a/api/api.swagger.json b/api/api.swagger.json
index b92ae526b..e016944fa 100644
--- a/api/api.swagger.json
+++ b/api/api.swagger.json
@@ -763,6 +763,64 @@
         ]
       }
     },
+    "/v2/match": {
+      "get": {
+        "summary": "Fetch list of running matches.",
+        "operationId": "ListMatches",
+        "responses": {
+          "200": {
+            "description": "",
+            "schema": {
+              "$ref": "#/definitions/apiMatchList"
+            }
+          }
+        },
+        "parameters": [
+          {
+            "name": "limit",
+            "description": "Limit the number of returned matches.",
+            "in": "query",
+            "required": false,
+            "type": "integer",
+            "format": "int32"
+          },
+          {
+            "name": "authoritative",
+            "description": "Authoritative or relayed matches.",
+            "in": "query",
+            "required": false,
+            "type": "boolean",
+            "format": "boolean"
+          },
+          {
+            "name": "label",
+            "description": "Label filter.",
+            "in": "query",
+            "required": false,
+            "type": "string"
+          },
+          {
+            "name": "min_size",
+            "description": "Minimum user count.",
+            "in": "query",
+            "required": false,
+            "type": "integer",
+            "format": "int32"
+          },
+          {
+            "name": "max_size",
+            "description": "Maximum user count.",
+            "in": "query",
+            "required": false,
+            "type": "integer",
+            "format": "int32"
+          }
+        ],
+        "tags": [
+          "Nakama"
+        ]
+      }
+    },
     "/v2/notification": {
       "get": {
         "summary": "Fetch list of notifications.",
@@ -1379,6 +1437,43 @@
       },
       "description": "A collection of zero or more groups."
     },
+    "apiMatch": {
+      "type": "object",
+      "properties": {
+        "match_id": {
+          "type": "string",
+          "description": "The ID of the match, can be used to join."
+        },
+        "authoritative": {
+          "type": "boolean",
+          "format": "boolean",
+          "description": "True if it's an server-managed authoritative match, false otherwise."
+        },
+        "label": {
+          "type": "string",
+          "description": "Match label, if any."
+        },
+        "size": {
+          "type": "integer",
+          "format": "int32",
+          "description": "Current number of users in the match."
+        }
+      },
+      "description": "Represents a realtime match."
+    },
+    "apiMatchList": {
+      "type": "object",
+      "properties": {
+        "matches": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/apiMatch"
+          },
+          "description": "A number of matches corresponding to a list operation."
+        }
+      },
+      "description": "A list of realtime matches."
+    },
     "apiNotification": {
       "type": "object",
       "properties": {
diff --git a/data/modules/clientrpc.lua b/data/modules/clientrpc.lua
index d7458966f..e1c6c37f5 100644
--- a/data/modules/clientrpc.lua
+++ b/data/modules/clientrpc.lua
@@ -63,3 +63,9 @@ local function send_stream_data(context, payload)
   nk.stream_send(stream, tostring(payload))
 end
 nk.register_rpc(send_stream_data, "clientrpc.send_stream_data")
+
+local function create_authoritative_match(_context, _payload)
+  local match_id = nk.match_create("match", {})
+  return nk.json_encode({ match_id = match_id })
+end
+nk.register_rpc(create_authoritative_match, "clientrpc.create_authoritative_match")
diff --git a/data/modules/debug_utils.lua b/data/modules/debug_utils.lua
new file mode 100644
index 000000000..66e42ddcb
--- /dev/null
+++ b/data/modules/debug_utils.lua
@@ -0,0 +1,45 @@
+--[[
+ Copyright 2018 The Nakama Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+--]]
+
+local function print_r(arr, indentLevel)
+  if type(arr) ~= "table" then
+    return tostring(arr)
+  end
+
+  local str = ""
+  local indentStr = "#"
+
+  if(indentLevel == nil) then
+    return print_r(arr, 0)
+  end
+
+  for i = 0, indentLevel do
+    indentStr = indentStr.."\t"
+  end
+
+  for index,Value in pairs(arr) do
+    if type(Value) == "table" then
+      str = str..indentStr..index..": \n"..print_r(Value, (indentLevel + 1))
+    else
+      str = str..indentStr..index..": "..tostring(Value).."\n"
+    end
+  end
+  return str
+end
+
+return {
+  print_r = print_r
+}
diff --git a/data/modules/match.lua b/data/modules/match.lua
new file mode 100644
index 000000000..b02832834
--- /dev/null
+++ b/data/modules/match.lua
@@ -0,0 +1,211 @@
+--[[
+ Copyright 2018 The Nakama Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+--]]
+
+local du = require("debug_utils")
+
+--[[
+Called when a match is created as a result of nk.match_create().
+
+Context represents information about the match and server, for information purposes. Format:
+{
+  Env = {}, -- key-value data set in the runtime.env server configuration.
+  ExecutionMode = "Match",
+  MatchId = "client-friendly match ID, can be shared with clients and used in match join operations",
+  MatchNode = "name of the Nakama node hosting this match"
+}
+
+Params is the optional arbitrary second argument passed to `nk.match_create()`, or `nil` if none was used.
+
+Expected return these values (all required) in order:
+1. The initial in-memory state of the match. May be any non-nil Lua term, or nil to end the match.
+2. Tick rate representing the desired number of match loop calls per second. Must be between 1 and 30, inclusive.
+3. A string label that can be used to filter matches in listing operations. Must be between 0 and 256 characters long.
+--]]
+local function match_init(context, params)
+  local state = {
+    debug = (params and params.debug) or false
+  }
+  if state.debug then
+    print("match init context:\n" .. du.print_r(context) .. "match init params:\n" .. du.print_r(params))
+  end
+  local tick_rate = 1
+  local label = "skill=100-150"
+
+  return state, tick_rate, label
+end
+
+--[[
+Called when a user attempts to join the match using the client's match join operation.
+
+Context represents information about the match and server, for information purposes. Format:
+{
+  Env = {}, -- key-value data set in the runtime.env server configuration.
+  ExecutionMode = "Match",
+  MatchId = "client-friendly match ID, can be shared with clients and used in match join operations",
+  MatchNode = "name of the Nakama node hosting this match",
+  MatchLabel = "the label string returned from match_init",
+  MatchTickrate = 1 -- the tick rate returned by match_init
+}
+
+Dispatcher exposes useful functions to the match. Format:
+{
+  broadcast_message = function(op_code, data, presences, sender),
+    -- numeric message op code
+    -- a data payload string, or nil
+    -- list of presences (a subset of match participants) to use as message targets, or nil to send to the whole match
+    -- a presence to tag on the message as the 'sender', or nil
+  match_kick = function(presences)
+    -- a list of presences to remove from the match
+}
+
+Tick is the current match tick number, starts at 0 and increments after every match_loop call. Does not increment with
+calls to match_join_attempt or match_leave.
+
+State is the current in-memory match state, may be any Lua term except nil.
+
+Presence is the user attempting to join the match. Format:
+{
+  UserId: "user unique ID",
+  SessionId: "session ID of the user's current connection",
+  Username: "user's unique username",
+  Node: "name of the Nakama node the user is connected to"
+}
+
+Expected return these values (all required) in order:
+1. An (optionally) updated state. May be any non-nil Lua term, or nil to end the match.
+2. Boolean true if the join attempt should be allowed, false otherwise.
+--]]
+local function match_join_attempt(context, dispatcher, tick, state, presence)
+  if state.debug then
+    print("match join attempt:\n" .. du.print_r(presence))
+  end
+  return state, true
+end
+
+--[[
+Called when one or more users have left the match for any reason, including connection loss.
+
+Context represents information about the match and server, for information purposes. Format:
+{
+  Env = {}, -- key-value data set in the runtime.env server configuration.
+  ExecutionMode = "Match",
+  MatchId = "client-friendly match ID, can be shared with clients and used in match join operations",
+  MatchNode = "name of the Nakama node hosting this match",
+  MatchLabel = "the label string returned from match_init",
+  MatchTickrate = 1 -- the tick rate returned by match_init
+}
+
+Dispatcher exposes useful functions to the match. Format:
+{
+  broadcast_message = function(op_code, data, presences, sender),
+    -- numeric message op code
+    -- a data payload string, or nil
+    -- list of presences (a subset of match participants) to use as message targets, or nil to send to the whole match
+    -- a presence to tag on the message as the 'sender', or nil
+  match_kick = function(presences)
+    -- a list of presences to remove from the match
+}
+
+Tick is the current match tick number, starts at 0 and increments after every match_loop call. Does not increment with
+calls to match_join_attempt or match_leave.
+
+State is the current in-memory match state, may be any Lua term except nil.
+
+Presences is a list of users that have left the match. Format:
+{
+  {
+    UserId: "user unique ID",
+    SessionId: "session ID of the user's current connection",
+    Username: "user's unique username",
+    Node: "name of the Nakama node the user is connected to"
+  },
+  ...
+}
+
+Expected return these values (all required) in order:
+1. An (optionally) updated state. May be any non-nil Lua term, or nil to end the match.
+--]]
+local function match_leave(context, dispatcher, tick, state, presences)
+  if state.debug then
+    print("match leave:\n" .. du.print_r(presences))
+  end
+  return state
+end
+
+--[[
+Called on an interval based on the tick rate returned by match_init.
+
+Context represents information about the match and server, for information purposes. Format:
+{
+  Env = {}, -- key-value data set in the runtime.env server configuration.
+  ExecutionMode = "Match",
+  MatchId = "client-friendly match ID, can be shared with clients and used in match join operations",
+  MatchNode = "name of the Nakama node hosting this match",
+  MatchLabel = "the label string returned from match_init",
+  MatchTickrate = 1 -- the tick rate returned by match_init
+}
+
+Dispatcher exposes useful functions to the match. Format:
+{
+  broadcast_message = function(op_code, data, presences, sender),
+    -- numeric message op code
+    -- a data payload string, or nil
+    -- list of presences (a subset of match participants) to use as message targets, or nil to send to the whole match
+    -- a presence to tag on the message as the 'sender', or nil
+  match_kick = function(presences)
+    -- a list of presences to remove from the match
+}
+
+Tick is the current match tick number, starts at 0 and increments after every match_loop call. Does not increment with
+calls to match_join_attempt or match_leave.
+
+State is the current in-memory match state, may be any Lua term except nil.
+
+Messages is a list of data messages received from users between the previous and current ticks. Format:
+{
+  {
+    Sender = {
+      UserId: "user unique ID",
+      SessionId: "session ID of the user's current connection",
+      Username: "user's unique username",
+      Node: "name of the Nakama node the user is connected to"
+    },
+    OpCode = 1, -- numeric op code set by the sender.
+    Data = "any string data set by the sender" -- may be nil.
+  },
+  ...
+}
+
+Expected return these values (all required) in order:
+1. An (optionally) updated state. May be any non-nil Lua term, or nil to end the match.
+--]]
+local function match_loop(context, dispatcher, tick, state, messages)
+  if state.debug then
+    print("match " .. context.MatchId .. " tick " .. tick)
+    print("match " .. context.MatchId .. " messages:\n" .. du.print_r(messages))
+  end
+  if tick < 180 then
+    return state
+  end
+end
+
+-- Match modules must return a table with these functions defined. All functions are required.
+return {
+  match_init = match_init,
+  match_join_attempt = match_join_attempt,
+  match_leave = match_leave,
+  match_loop = match_loop
+}
diff --git a/data/modules/match_init.lua b/data/modules/match_init.lua
new file mode 100644
index 000000000..400486798
--- /dev/null
+++ b/data/modules/match_init.lua
@@ -0,0 +1,17 @@
+--[[
+ Copyright 2018 The Nakama Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+--]]
+
+--require("nakama").match_create("match", {debug = true})
diff --git a/main.go b/main.go
index 326cf9dca..b23c857d9 100644
--- a/main.go
+++ b/main.go
@@ -32,6 +32,7 @@ import (
 	"github.com/heroiclabs/nakama/social"
 	_ "github.com/lib/pq"
 	"go.uber.org/zap"
+	"sync"
 )
 
 var (
@@ -74,28 +75,40 @@ func main() {
 	jsonLogger, multiLogger := server.SetupLogging(config)
 
 	multiLogger.Info("Nakama starting")
-	multiLogger.Info("Node", zap.String("name", config.GetName()), zap.String("version", semver), zap.String("runtime", runtime.Version()))
+	multiLogger.Info("Node", zap.String("name", config.GetName()), zap.String("version", semver), zap.String("runtime", runtime.Version()), zap.Int("cpu", runtime.NumCPU()))
 	multiLogger.Info("Data directory", zap.String("path", config.GetDataDir()))
 	multiLogger.Info("Database connections", zap.Strings("dsns", config.GetDatabase().Addresses))
 
 	db, dbVersion := dbConnect(multiLogger, config)
 	multiLogger.Info("Database information", zap.String("version", dbVersion))
 
-	// Check migration status and log if the schema has diverged.
+	// Check migration status and fail fast if the schema has diverged.
 	migrate.StartupCheck(multiLogger, db)
 
+	// Access to social provider integrations.
 	socialClient := social.NewClient(5 * time.Second)
+	// Used to govern once-per-server-start executions in all Lua runtime instances, across both pooled and match VMs.
+	once := &sync.Once{}
 
 	// Start up server components.
-	registry := server.NewSessionRegistry()
-	tracker := server.StartLocalTracker(jsonLogger, registry, jsonpbMarshaler, config.GetName())
-	router := server.NewLocalMessageRouter(registry, tracker, jsonpbMarshaler)
-	runtimePool, err := server.NewRuntimePool(jsonLogger, multiLogger, db, config, socialClient, registry, tracker, router)
+	sessionRegistry := server.NewSessionRegistry()
+	tracker := server.StartLocalTracker(jsonLogger, sessionRegistry, jsonpbMarshaler, config.GetName())
+	router := server.NewLocalMessageRouter(sessionRegistry, tracker, jsonpbMarshaler)
+	stdLibs, modules, err := server.LoadRuntimeModules(jsonLogger, multiLogger, config)
+	if err != nil {
+		multiLogger.Fatal("Failed reading runtime modules", zap.Error(err))
+	}
+	matchRegistry := server.NewLocalMatchRegistry(jsonLogger, db, config, socialClient, sessionRegistry, tracker, router, stdLibs, once, config.GetName())
+	tracker.SetMatchLeaveListener(matchRegistry.Leave)
+	// Separate module evaluation/validation from module loading.
+	// We need the match registry to be available to wire all functions exposed to the runtime, which in turn needs the modules at least cached first.
+	regRPC, err := server.ValidateRuntimeModules(jsonLogger, multiLogger, db, config, socialClient, sessionRegistry, matchRegistry, tracker, router, stdLibs, modules, once)
 	if err != nil {
 		multiLogger.Fatal("Failed initializing runtime modules", zap.Error(err))
 	}
-	pipeline := server.NewPipeline(config, db, registry, tracker, router, runtimePool)
-	apiServer := server.StartApiServer(jsonLogger, db, jsonpbMarshaler, jsonpbUnmarshaler, config, socialClient, registry, tracker, router, pipeline, runtimePool)
+	runtimePool := server.NewRuntimePool(jsonLogger, multiLogger, db, config, socialClient, sessionRegistry, matchRegistry, tracker, router, stdLibs, modules, regRPC, once)
+	pipeline := server.NewPipeline(config, db, sessionRegistry, matchRegistry, tracker, router, runtimePool)
+	apiServer := server.StartApiServer(jsonLogger, db, jsonpbMarshaler, jsonpbUnmarshaler, config, socialClient, sessionRegistry, matchRegistry, tracker, router, pipeline, runtimePool)
 
 	// Respect OS stop signals.
 	c := make(chan os.Signal, 2)
@@ -109,8 +122,9 @@ func main() {
 
 	// Gracefully stop server components.
 	apiServer.Stop()
+	matchRegistry.Stop()
 	tracker.Stop()
-	registry.Stop()
+	sessionRegistry.Stop()
 
 	os.Exit(0)
 }
diff --git a/migrate/migrate-packr.go b/migrate/migrate-packr.go
index faa91fd32..78312be84 100644
--- a/migrate/migrate-packr.go
+++ b/migrate/migrate-packr.go
@@ -9,5 +9,5 @@ import "github.com/gobuffalo/packr"
 // Go binary. You can use the "packr clean" command to clean up this,
 // and any other packr generated files.
 func init() {
-	packr.PackJSONBytes("./sql", "20180103142001_initial_schema.sql", "\"H4sIAAAAAAAA/6RX23ObzhV+119xxg+19Cu62EmmmbjNDJZwQqMgV6AkftKs4Ai2hl2yu0hRO/3fO8tFAqxL3PJkw7ffuX17ztHwjw78AWOe7gQNIwW3o5v34EUIDnkmCQEzUxEXsgM5bkp9ZBIDyFiAAlSEYKbEj7D6YsA3FJJyBreDEXQ14Kr8dNW70xQ7nkFCdsC4gkwiqIhKWNMYAX/5mCqgDHyepDElzEfYUhXldkqWgeZ4Kjn4ShHKgIDP0x3wdR0IRJVOR0qlH4bD7XY7ILmzAy7CYVzA5HBqjy3Htfq3g1F5YMFilBIE/syowABWOyBpGlOfrGKEmGyBCyChQAxAce3wVlBFWWiA5Gu1JQI1TUClEnSVqUa+KveobAA4A8LgynTBdq/g3nRt19Ak323v82zhwXdzPjcdz7ZcmM1hPHMmtmfPHBdmD2A6T/DFdiYGIFURCsBfqdARcAFUZxKDPG0uYsOFNS9ckin6dE19iAkLMxIihHyDglEWQooioVJXVAJhgaaJaUIVUfmrF3FpQ8NOp9+HPyc0FEQhLNLOeG6ZngWeeT+1wH4AZ+aB9cN2PVdrQEjodgAAHuf2V3P+BF+sJ+jSoGd08tc0gNqzWNiTw3+ayVlMp0aO1GSMJFh8+2bOx5/Neffm9n0PdM5cb27ajlfYXFbg5TPuYOHY/1hYLbqAyjQmu2VBWdHdvnvXK76TDVFELDMR180dvvf7ufjkh+FQcR7LAUW1ztUXqSQervz07V9yoE78UpGw5bd2GybWg7mYenCN7Lqgjbmfp7+Jzs1qkzgIB3DlEgYPgjCfSp8bMDav8rOKJvgvzvDs2UdS6MGjCUJ34cKfYEwYCUivIElQkYAoUpD83Z059/uC7N3993+uW+nckjhGVQF/+xgmhMZ7YN1lKMtW4FIi5ZaLUiz3T55l7k+NP1vjL9CNkYUq6lbIHvwV3tyORqOyXmvi44rz52WuuKZ86pZCzsMYl6Uuz+BIgj4yhUJjT+OkQpJUdGdwfiYVTy7bxSDEpc8zlidbKx5eJHpU5aQG/vg3GPVa2fcFEoVLrRudVftTja5kqEM+vmTI0uASQx1yhGGDgq53ZxnqkCKMQ6AvLrXu4yXbUbIG5BRbp3fXudTYlgFuqI8n25t++zCbW/Ynp3ibH6JBD+bWgzW3nLFVdsgcr2+oN5vMPsDMgYk1tTwLxqY7NidWq1U2tPGyR2oJNfroq4LSijkWkuSZ8PW1MEAqotCAlEuqO9WxUPfo/yXYF2wBSkVZ3hb/n/ztnSrSss9RM4VVWGXy9hrao7Shhd6T9IiVXCjgQg9KzkDwrTxyLQ4kv3clmvGecjQvw+Huu1/N6VRbOXI3tMtrQZEF3VHPAMo2VGH3Zv9n0L3tGbCKuf+MQfdNz4AAY9Tv3/YMIMKP6AaD7rtekchyntY10SrRJakxrvQQKibdEbWVOjYaHcp0xwZUpX4UuEahNzhdAr3NFFWQEc/iACKyQVghMphY7hhWmYIx958FJ350LeFnhmIHhJF4J1HofU2vrH6MG71hMZ6F0eDIetJeUBqLRz2mJQ1Obx7VJT3F2Spztvon+vvJ2pqRbbTPmUJ2QDcH8dlR7PMA627sBdV2q/i33wcHQ6LoBmFD4gwlEIEgd1JhAgIlig0GRQioN8lDyDpgA04+mnkxnRb3q6BLUEoSojwytdpt/vLkuiROqbggx7ugz+MYfV1hAwSSwIBn3BlVRXun61uluDrerGUx5du11AKqP+fReQ1q6FdUflP+rKue5nrVNqTjPqeTytBNVYj8wLH1Q/+ywtcwFQcubzKv18SxbeYky5n2XeuPtSZWU80z7hrd0XYm1o9jg5gGS5245eGw7ihLGvzSA26v0b2RQo4HtLbR78OUSgXEF1zKYlgOzlk+ZrHypm25bbB1F+7O2amZyE2es3Phzt01f5BO+JZ1JvPZ4+FqN6/13fGv9e59ArLfj859L5bCMwh51/lvAAAA//8SHZVZmBEAAA==\"")
-	}
+	packr.PackJSONBytes("./sql", "20180103142001_initial_schema.sql", "\"H4sIAAAAAAAA/7RX33PithZ+5684k4cbvNcBwm7m7mxud8YBZ9ddYlJsdjd9YYR9sNXYkisJCO30f+/IP8AGEtJOy1MiffrOD30657j7pgVvYMCzjaBRrKDfu3wPfozgkkeSErCWKuZCtiDHjWiATGIISxaiABUjWBkJYqx2TPiKQlLOoN/pQVsDzsqtM+NaU2z4ElKyAcYVLCWCiqmEBU0Q8CnATAFlEPA0SyhhAcKaqji3U7J0NMdDycHnilAGBAKebYAv6kAgqnQ6Vir70O2u1+sOyZ3tcBF1kwImuyNnYLuefdHv9MoDU5aglCDw1yUVGMJ8AyTLEhqQeYKQkDVwASQSiCEorh1eC6ooi0yQfKHWRKCmCalUgs6XqpGvyj0qGwDOgDA4szxwvDO4sTzHMzXJN8f/PJ768M2aTCzXd2wPxhMYjN2h4ztj14PxLVjuA3xx3KEJSFWMAvApEzoCLoDqTGKYp81DbLiw4IVLMsOALmgACWHRkkQIEV+hYJRFkKFIqdQ3KoGwUNMkNKWKqHzpIC5tqNtqXVzAf1MaCaIQpllrMLEt3wbfuhnZ4NyCO/bB/u54vqc1ICS0WwAA9xPnzpo8wBf7Ado0NMxWvkxDqP2mU2e4+08zudPRyMyRmoyRFIu9r9Zk8NmatC/77w3QOfP8ieW4fmFzVoFnj7iBqev8NLX36EIqs4RsZgVlRde/ujKKfbIiiojZUiR1c7v9i4tcfPJDt6s4T2SHolrk6otVmnTnQfbufzlQJ36mSLTnt3YbhvatNR35cI7svKBNeJCnv4nOzWqT2Ik6cOYRBreCsIDKgJswsM7ys4qm+Btn+OLZe1LowacpQnvqwX9gQBgJiVGQpKhISBQpSH70xu7N9kK27v7+x/leOtckSVBVwFcfw5TQZAusuwzltRW4jEi55qIUy82Db1vbU4PP9uALtBNkkYrbFdKA/8Pbfq/XK+9rQQKcc/44yxXXlE/dUsR5lOCs1OULOJJigEyh0NjncVIhSSu6F3DBUiqenraLYYSzgC9ZnmyteDhIdK/KSQ388QfoGXvZDwQShTOtG51V51ONrmSoQz4eMiyz8BRDHXKEYYWCLjbHGQ7iqYOPBRRSqet4yXaCrAFusrWM69apwjYLcUUDfLa86dXb8cR2PrnFan6IhgZM7Ft7YrsDe1sh9erYhaE9sn0bBpY3sIb2XoVsSOKwNGrlNMrnX4pFC+VYJJIvRaBfgwlSEYUmZFxSXaCORbhFG4ULrw70gClEqSjLK+HfSNnWj8KNbVqaWasiKfO1VcsWpYvmVE9EuplKLhRwoVsiZyD4WnaOvIAdy+vU34zzOU/z1O+euXdnjUbayk7SDZ8XgiIL2z3DBMpWVGH7cvtn2O4bJswTHjxi2H5rmBBignr9nWECEUFMVxi2r4wik2XrrOtg72pOyYtxpftN0dTaVeu8db7f2R8g4MGj4CSIz/UEQpKNRKGnJz1ABgmu9LzD+DKKYR0ja9SrmEgY2t4AUh6ivhI9qFAW4lPnUMblAzEbDJY3MOFfeaqwP8rU5pN6PmY0fH5AqR71AdmeNpbzXzBQjRKR98+9Us+ZwqJnNLvzC8050Jnd1xzsvxAXI6LoCmFFkiVKIAJBbqTCFARKFKt8QtWeoh4oi5B0QCYc/DTddDQqHlzBkaKUJMLytdXvr1HeT7WrUzKVigtyvAYGPEkw0PdlgkASmvCIG7O6n39QPjtDL1Z7rZjq94qmUOmnbLf6ksrDr9XBqvzsqxt82zf2dJCGV/pRxvpbrbDCc2UWF6cTd1RLld3L6gpz5LHmrj/E8FUUBfLUxOM7d7bnW3f3/s8H3xq1qv0MrFEe6xJpiEPLrlSd4w7t73uq252b6bBn5TFdFGY0fNJK2QrzUIbbqvaIGy3v+ofZkK9ZazgZ3+/U3lT69fHdenl6BrIdGF7aL4ajFxDyuvVnAAAA///9ex2coBAAAA==\"")
+}
diff --git a/migrate/sql/20180103142001_initial_schema.sql b/migrate/sql/20180103142001_initial_schema.sql
index 40fbee213..f1387f130 100644
--- a/migrate/sql/20180103142001_initial_schema.sql
+++ b/migrate/sql/20180103142001_initial_schema.sql
@@ -38,8 +38,8 @@ CREATE TABLE IF NOT EXISTS users (
     edge_count    INT           DEFAULT 0 CHECK (edge_count >= 0) NOT NULL,
     create_time   BIGINT        CHECK (create_time > 0) NOT NULL,
     update_time   BIGINT        CHECK (update_time > 0) NOT NULL,
-    verify_time   BIGINT        CHECK (verify_time >= 0) DEFAULT 0 NOT NULL,
-    disable_time  BIGINT        CHECK (disable_time >= 0) DEFAULT 0 NOT NULL
+    verify_time   BIGINT        DEFAULT 0 CHECK (verify_time >= 0) NOT NULL,
+    disable_time  BIGINT        DEFAULT 0 CHECK (disable_time >= 0) NOT NULL
 );
 
 CREATE TABLE IF NOT EXISTS user_device (
@@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS user_edge (
 );
 
 CREATE TABLE IF NOT EXISTS notification (
-    -- FIXME: cockroach's analyser is not clever enough when create_time has DESC mode on the index. 
+    -- FIXME: cockroach's analyser is not clever enough when create_time has DESC mode on the index.
     PRIMARY KEY (user_id, create_time ASC, id),
     FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
 
diff --git a/rtapi/realtime.pb.go b/rtapi/realtime.pb.go
index b6ebffa33..3dccbf177 100644
--- a/rtapi/realtime.pb.go
+++ b/rtapi/realtime.pb.go
@@ -28,6 +28,7 @@ package rtapi
 import proto "github.com/golang/protobuf/proto"
 import fmt "fmt"
 import math "math"
+import google_protobuf "github.com/golang/protobuf/ptypes/wrappers"
 import nakama_api "github.com/heroiclabs/nakama/api"
 
 // Reference imports to suppress errors if they are not otherwise used.
@@ -55,10 +56,12 @@ const (
 	Error_BAD_INPUT Error_Code = 3
 	// The match id was not found.
 	Error_MATCH_NOT_FOUND Error_Code = 4
+	// The match join was rejected.
+	Error_MATCH_JOIN_REJECTED Error_Code = 5
 	// The runtime function does not exist on the server.
-	Error_RUNTIME_FUNCTION_NOT_FOUND Error_Code = 5
+	Error_RUNTIME_FUNCTION_NOT_FOUND Error_Code = 6
 	// The runtime function executed with an error.
-	Error_RUNTIME_FUNCTION_EXCEPTION Error_Code = 6
+	Error_RUNTIME_FUNCTION_EXCEPTION Error_Code = 7
 )
 
 var Error_Code_name = map[int32]string{
@@ -67,8 +70,9 @@ var Error_Code_name = map[int32]string{
 	2: "MISSING_PAYLOAD",
 	3: "BAD_INPUT",
 	4: "MATCH_NOT_FOUND",
-	5: "RUNTIME_FUNCTION_NOT_FOUND",
-	6: "RUNTIME_FUNCTION_EXCEPTION",
+	5: "MATCH_JOIN_REJECTED",
+	6: "RUNTIME_FUNCTION_NOT_FOUND",
+	7: "RUNTIME_FUNCTION_EXCEPTION",
 }
 var Error_Code_value = map[string]int32{
 	"RUNTIME_EXCEPTION":          0,
@@ -76,8 +80,9 @@ var Error_Code_value = map[string]int32{
 	"MISSING_PAYLOAD":            2,
 	"BAD_INPUT":                  3,
 	"MATCH_NOT_FOUND":            4,
-	"RUNTIME_FUNCTION_NOT_FOUND": 5,
-	"RUNTIME_FUNCTION_EXCEPTION": 6,
+	"MATCH_JOIN_REJECTED":        5,
+	"RUNTIME_FUNCTION_NOT_FOUND": 6,
+	"RUNTIME_FUNCTION_EXCEPTION": 7,
 }
 
 func (x Error_Code) String() string {
@@ -563,9 +568,18 @@ func (m *Error) GetContext() map[string]string {
 
 // A realtime match.
 type Match struct {
-	MatchId   string            `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
-	Presences []*StreamPresence `protobuf:"bytes,2,rep,name=presences" json:"presences,omitempty"`
-	Self      *StreamPresence   `protobuf:"bytes,3,opt,name=self" json:"self,omitempty"`
+	// The match unique ID.
+	MatchId string `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
+	// True if it's an server-managed authoritative match, false otherwise.
+	Authoritative bool `protobuf:"varint,2,opt,name=authoritative" json:"authoritative,omitempty"`
+	// Match label, if any.
+	Label *google_protobuf.StringValue `protobuf:"bytes,3,opt,name=label" json:"label,omitempty"`
+	// The number of users currently in the match.
+	Size int32 `protobuf:"varint,4,opt,name=size" json:"size,omitempty"`
+	// The users currently in the match.
+	Presences []*StreamPresence `protobuf:"bytes,5,rep,name=presences" json:"presences,omitempty"`
+	// A reference to the current user's presence in the match.
+	Self *StreamPresence `protobuf:"bytes,6,opt,name=self" json:"self,omitempty"`
 }
 
 func (m *Match) Reset()                    { *m = Match{} }
@@ -580,6 +594,27 @@ func (m *Match) GetMatchId() string {
 	return ""
 }
 
+func (m *Match) GetAuthoritative() bool {
+	if m != nil {
+		return m.Authoritative
+	}
+	return false
+}
+
+func (m *Match) GetLabel() *google_protobuf.StringValue {
+	if m != nil {
+		return m.Label
+	}
+	return nil
+}
+
+func (m *Match) GetSize() int32 {
+	if m != nil {
+		return m.Size
+	}
+	return 0
+}
+
 func (m *Match) GetPresences() []*StreamPresence {
 	if m != nil {
 		return m.Presences
@@ -605,10 +640,14 @@ func (*MatchCreate) Descriptor() ([]byte, []int) { return fileDescriptor0, []int
 
 // Realtime match data received from the server.
 type MatchData struct {
-	MatchId  string          `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
+	// The match unique ID.
+	MatchId string `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
+	// A reference to the user presence that sent this data, if any.
 	Presence *StreamPresence `protobuf:"bytes,2,opt,name=presence" json:"presence,omitempty"`
-	OpCode   int64           `protobuf:"varint,3,opt,name=op_code,json=opCode" json:"op_code,omitempty"`
-	Data     []byte          `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
+	// Op code value.
+	OpCode int64 `protobuf:"varint,3,opt,name=op_code,json=opCode" json:"op_code,omitempty"`
+	// Data payload, if any.
+	Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
 }
 
 func (m *MatchData) Reset()                    { *m = MatchData{} }
@@ -646,9 +685,13 @@ func (m *MatchData) GetData() []byte {
 
 // Send realtime match data to the server.
 type MatchDataSend struct {
-	MatchId   string            `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
-	OpCode    int64             `protobuf:"varint,2,opt,name=op_code,json=opCode" json:"op_code,omitempty"`
-	Data      []byte            `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
+	// The match unique ID.
+	MatchId string `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
+	// Op code value.
+	OpCode int64 `protobuf:"varint,2,opt,name=op_code,json=opCode" json:"op_code,omitempty"`
+	// Data payload, if any.
+	Data []byte `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
+	// List of presences in the match to deliver to, if filtering is required. Otherwise deliver to everyone in the match.
 	Presences []*StreamPresence `protobuf:"bytes,4,rep,name=presences" json:"presences,omitempty"`
 }
 
@@ -801,6 +844,7 @@ func _MatchJoin_OneofSizer(msg proto.Message) (n int) {
 
 // Leave a realtime match.
 type MatchLeave struct {
+	// The match unique ID.
 	MatchId string `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
 }
 
@@ -818,9 +862,12 @@ func (m *MatchLeave) GetMatchId() string {
 
 // A set of joins and leaves on a particular realtime match.
 type MatchPresenceEvent struct {
-	MatchId string            `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
-	Joins   []*StreamPresence `protobuf:"bytes,2,rep,name=joins" json:"joins,omitempty"`
-	Leaves  []*StreamPresence `protobuf:"bytes,3,rep,name=leaves" json:"leaves,omitempty"`
+	// The match unique ID.
+	MatchId string `protobuf:"bytes,1,opt,name=match_id,json=matchId" json:"match_id,omitempty"`
+	// User presences that have just joined the match.
+	Joins []*StreamPresence `protobuf:"bytes,2,rep,name=joins" json:"joins,omitempty"`
+	// User presences that have just left the match.
+	Leaves []*StreamPresence `protobuf:"bytes,3,rep,name=leaves" json:"leaves,omitempty"`
 }
 
 func (m *MatchPresenceEvent) Reset()                    { *m = MatchPresenceEvent{} }
@@ -1059,71 +1106,78 @@ func init() {
 func init() { proto.RegisterFile("rtapi/realtime.proto", fileDescriptor0) }
 
 var fileDescriptor0 = []byte{
-	// 1056 bytes of a gzipped FileDescriptorProto
-	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x56, 0x4d, 0x6f, 0xdb, 0x46,
-	0x10, 0x15, 0x25, 0x91, 0x16, 0x47, 0x56, 0xec, 0xae, 0x3f, 0xc2, 0x3a, 0xad, 0x6b, 0x30, 0x05,
-	0x1a, 0xf4, 0x20, 0x03, 0x09, 0x8a, 0x16, 0x09, 0x12, 0xd4, 0x92, 0xe5, 0x50, 0x6d, 0x4c, 0x09,
-	0x6b, 0x1b, 0x6d, 0x7d, 0x21, 0xd6, 0xe4, 0x26, 0x66, 0x2c, 0x7e, 0x80, 0xbb, 0x32, 0xea, 0xbf,
-	0xd0, 0x53, 0x4f, 0x3d, 0xb6, 0x40, 0x8f, 0x3d, 0xe5, 0xd4, 0x3f, 0xd4, 0x3f, 0x52, 0xec, 0x92,
-	0x14, 0x29, 0x59, 0x92, 0x93, 0x4b, 0x4e, 0xe2, 0xec, 0xbe, 0x37, 0x33, 0x7a, 0x3b, 0xfb, 0x48,
-	0xd8, 0x4c, 0x38, 0x89, 0xfd, 0xfd, 0x84, 0x92, 0x11, 0xf7, 0x03, 0xda, 0x8e, 0x93, 0x88, 0x47,
-	0x68, 0x2d, 0x24, 0x57, 0x24, 0x20, 0xed, 0x7c, 0x79, 0xe7, 0xeb, 0x37, 0x3e, 0xbf, 0x1c, 0x5f,
-	0xb4, 0xdd, 0x28, 0xd8, 0xbf, 0xa4, 0x49, 0xe4, 0xbb, 0x23, 0x72, 0xc1, 0xf6, 0x53, 0xd8, 0xbe,
-	0xc8, 0x40, 0x62, 0x3f, 0x25, 0x9b, 0xef, 0x34, 0x68, 0xf4, 0xc2, 0x6b, 0x3a, 0x8a, 0x62, 0x8a,
-	0xd6, 0xa1, 0xe6, 0xfa, 0x9e, 0xa1, 0xec, 0x29, 0x8f, 0x74, 0x2c, 0x1e, 0x51, 0x1b, 0x54, 0x9a,
-	0x24, 0x51, 0x62, 0x54, 0xf7, 0x94, 0x47, 0xcd, 0xc7, 0xdb, 0xed, 0x99, 0x5a, 0xed, 0x9e, 0xd8,
-	0xb5, 0x2a, 0x38, 0x85, 0x09, 0x7c, 0x40, 0xb8, 0x7b, 0x69, 0xd4, 0x16, 0xe0, 0x8f, 0xc5, 0xae,
-	0xc0, 0x4b, 0x18, 0x3a, 0x80, 0x55, 0xf9, 0xe0, 0xb8, 0x09, 0x25, 0x9c, 0x1a, 0x75, 0x49, 0xfb,
-	0x6c, 0x3e, 0xad, 0x2b, 0x31, 0x56, 0x05, 0x37, 0x83, 0x22, 0x44, 0xcf, 0x00, 0xd2, 0x14, 0x1e,
-	0xe1, 0xc4, 0x50, 0x65, 0x82, 0x9d, 0xf9, 0x09, 0x0e, 0x09, 0x27, 0x56, 0x05, 0xeb, 0x41, 0x1e,
-	0x20, 0x0b, 0xd6, 0x0a, 0xb2, 0xc3, 0x68, 0xe8, 0x19, 0x9a, 0xcc, 0xb0, 0xbb, 0x38, 0xc3, 0x09,
-	0x0d, 0x3d, 0xab, 0x82, 0x5b, 0x41, 0x79, 0xa1, 0x68, 0xe3, 0x6d, 0xe4, 0x87, 0xc6, 0xca, 0xb2,
-	0x36, 0x7e, 0x88, 0xfc, 0x70, 0xd2, 0x86, 0x08, 0xd0, 0x0b, 0x48, 0xff, 0x92, 0x33, 0xa2, 0xe4,
-	0x9a, 0x1a, 0x0d, 0xc9, 0x7e, 0x30, 0x9f, 0xfd, 0x4a, 0x40, 0xac, 0x0a, 0x4e, 0xcb, 0xc9, 0x08,
-	0xfd, 0x04, 0x9b, 0x29, 0x3f, 0x4e, 0x28, 0xa3, 0xa1, 0x4b, 0x1d, 0x7a, 0x4d, 0x43, 0x6e, 0xe8,
-	0x32, 0xd1, 0xc3, 0xf9, 0x89, 0x86, 0x19, 0xb6, 0x27, 0xa0, 0x56, 0x05, 0xa3, 0xe0, 0xd6, 0x2a,
-	0x3a, 0x82, 0x56, 0x18, 0x71, 0xff, 0xb5, 0xef, 0x12, 0xee, 0x47, 0x21, 0x33, 0x60, 0x81, 0x3a,
-	0x76, 0x19, 0x25, 0xd4, 0x99, 0xa2, 0xa1, 0x87, 0x50, 0x4b, 0x62, 0xd7, 0x68, 0x4a, 0xf6, 0x5a,
-	0xce, 0x16, 0x63, 0x88, 0x63, 0xd7, 0xaa, 0x60, 0xb1, 0x2b, 0x54, 0x60, 0x3c, 0xa1, 0x24, 0x48,
-	0x8f, 0x72, 0x75, 0x81, 0x0a, 0x27, 0x12, 0x93, 0x9d, 0x25, 0xb0, 0x49, 0x84, 0xce, 0x61, 0x2b,
-	0xe3, 0xcf, 0xc8, 0xd0, 0x92, 0x99, 0xbe, 0x5c, 0x90, 0x69, 0x56, 0x87, 0x0d, 0x76, 0x7b, 0xb9,
-	0xa3, 0xc3, 0x4a, 0x40, 0x19, 0x23, 0x6f, 0xa8, 0xf9, 0x5f, 0x15, 0x54, 0x39, 0xf6, 0x08, 0x41,
-	0xdd, 0x8d, 0x3c, 0x2a, 0x2f, 0x8c, 0x8a, 0xe5, 0x33, 0x32, 0x26, 0x40, 0x79, 0x67, 0x74, 0x9c,
-	0x87, 0xe8, 0x39, 0xac, 0xb8, 0x51, 0xc8, 0xe9, 0xaf, 0xdc, 0xa8, 0xed, 0xd5, 0xe6, 0x9e, 0x8b,
-	0x4c, 0xdb, 0xee, 0xa6, 0xa8, 0x5e, 0xc8, 0x93, 0x1b, 0x9c, 0x73, 0x76, 0x9e, 0xc2, 0x6a, 0x79,
-	0x43, 0x5c, 0xd6, 0x2b, 0x7a, 0x93, 0x5f, 0xd6, 0x2b, 0x7a, 0x83, 0x36, 0x41, 0xbd, 0x26, 0xa3,
-	0x71, 0x5e, 0x38, 0x0d, 0x9e, 0x56, 0xbf, 0x53, 0xcc, 0x77, 0x0a, 0xd4, 0xbb, 0xa2, 0xbb, 0x2d,
-	0xf8, 0x04, 0x9f, 0xd9, 0xa7, 0xfd, 0xe3, 0x9e, 0xd3, 0xfb, 0xb9, 0xdb, 0x1b, 0x9e, 0xf6, 0x07,
-	0xf6, 0x7a, 0x05, 0x19, 0xb0, 0x79, 0x66, 0xe3, 0x5e, 0x77, 0xf0, 0xd2, 0xee, 0x9f, 0xf7, 0x0e,
-	0x9d, 0xe1, 0xc1, 0x2f, 0xaf, 0x06, 0x07, 0x87, 0xeb, 0x0a, 0xda, 0x80, 0xb5, 0xe3, 0xfe, 0xc9,
-	0x49, 0xdf, 0x7e, 0x39, 0x59, 0xac, 0xa2, 0x16, 0xe8, 0x9d, 0x83, 0x43, 0xa7, 0x6f, 0x0f, 0xcf,
-	0x4e, 0xd7, 0x6b, 0x12, 0x73, 0x70, 0xda, 0xb5, 0x1c, 0x7b, 0x70, 0xea, 0x1c, 0x0d, 0xce, 0xec,
-	0xc3, 0xf5, 0x3a, 0xda, 0x85, 0x9d, 0xbc, 0xd2, 0xd1, 0x99, 0xdd, 0x15, 0x85, 0x4a, 0xfb, 0xea,
-	0xdc, 0xfd, 0xa2, 0x25, 0xcd, 0xfc, 0x43, 0x01, 0x55, 0x8e, 0x29, 0xfa, 0x14, 0x1a, 0xe9, 0x70,
-	0x4f, 0xac, 0x69, 0x45, 0xc6, 0x7d, 0x0f, 0x3d, 0x07, 0x3d, 0x3f, 0x6a, 0x66, 0x54, 0xa5, 0xa8,
-	0x5f, 0xdc, 0x71, 0xca, 0xb8, 0x60, 0xa0, 0x27, 0x50, 0x67, 0x74, 0xf4, 0x3a, 0x33, 0xab, 0x3b,
-	0x99, 0x12, 0x6c, 0xb6, 0xa0, 0x59, 0x72, 0x23, 0xf3, 0x77, 0x05, 0xf4, 0x89, 0x35, 0x2c, 0xeb,
-	0xf5, 0x19, 0x34, 0xf2, 0xca, 0x99, 0x9b, 0xde, 0x59, 0x70, 0x42, 0x40, 0xf7, 0x61, 0x25, 0x8a,
-	0x1d, 0x39, 0x6c, 0xa2, 0xd9, 0x1a, 0xd6, 0xa2, 0x58, 0x1e, 0x28, 0x82, 0xba, 0xbc, 0x2c, 0xc2,
-	0x38, 0x57, 0xb1, 0x7c, 0x16, 0xd2, 0xb5, 0xa6, 0xdc, 0x6a, 0x59, 0x5b, 0xa5, 0xcc, 0xd5, 0xb9,
-	0x99, 0x6b, 0x45, 0xe6, 0x69, 0xbd, 0xeb, 0x1f, 0xaa, 0xb7, 0x79, 0x94, 0x49, 0x25, 0x3d, 0xef,
-	0xc1, 0x6c, 0x4f, 0x56, 0xa5, 0xe8, 0x6a, 0x1b, 0x54, 0x1e, 0x5d, 0xd1, 0x30, 0x1d, 0x65, 0xf1,
-	0xbe, 0x90, 0x61, 0xa7, 0x0e, 0x55, 0xdf, 0x33, 0xbf, 0x02, 0x28, 0xac, 0x70, 0xc9, 0x9f, 0x33,
-	0xff, 0x52, 0x00, 0xdd, 0xf6, 0xba, 0x65, 0x72, 0x7c, 0x03, 0xaa, 0x30, 0xf0, 0xf7, 0x9e, 0xa6,
-	0x14, 0x8d, 0xbe, 0x05, 0x4d, 0x5a, 0x37, 0xcb, 0xae, 0xf6, 0x9d, 0xbc, 0x0c, 0x6e, 0x0e, 0xa0,
-	0x35, 0x65, 0x9d, 0xe8, 0xc5, 0xac, 0xe3, 0x2a, 0x32, 0xa1, 0x51, 0xf6, 0xcc, 0x32, 0x63, 0xc6,
-	0x69, 0xcd, 0x11, 0x68, 0x69, 0x29, 0x71, 0x80, 0x41, 0xc9, 0x9d, 0x82, 0xcc, 0x9d, 0xd8, 0xf8,
-	0xe2, 0x2d, 0x75, 0x79, 0xee, 0x4e, 0x59, 0x88, 0x76, 0x01, 0x3c, 0xca, 0xdc, 0xc4, 0x8f, 0x79,
-	0x94, 0xc8, 0x43, 0xd7, 0x71, 0x69, 0x45, 0x98, 0xcb, 0x88, 0x5c, 0xd0, 0x91, 0x9c, 0x34, 0x1d,
-	0xa7, 0x81, 0xf9, 0x9b, 0x02, 0x50, 0xf8, 0x31, 0xda, 0x07, 0x2d, 0x35, 0x4f, 0x59, 0xb4, 0xf9,
-	0xf8, 0xfe, 0x02, 0x19, 0x70, 0x06, 0x13, 0xba, 0x89, 0x97, 0x2e, 0x4d, 0xde, 0xf7, 0x4a, 0x64,
-	0xf0, 0xa9, 0xe9, 0xd4, 0xb3, 0xb9, 0xff, 0x53, 0x81, 0x7b, 0xd3, 0x70, 0x31, 0xdd, 0x63, 0x46,
-	0x93, 0xe2, 0xa0, 0x35, 0x11, 0xf6, 0x3d, 0xf4, 0x39, 0x00, 0xa3, 0x8c, 0xf9, 0x51, 0x28, 0xf6,
-	0x52, 0x2d, 0xf4, 0x6c, 0xa5, 0xef, 0xa1, 0x1d, 0x68, 0x08, 0x60, 0x48, 0x02, 0x9a, 0x95, 0x98,
-	0xc4, 0x68, 0x0f, 0x9a, 0x31, 0x4d, 0x98, 0xcf, 0xb8, 0xbc, 0xcb, 0x42, 0x8f, 0x06, 0x2e, 0x2f,
-	0xa1, 0x6d, 0x21, 0x03, 0xe1, 0x63, 0x26, 0x3f, 0x47, 0x74, 0x9c, 0x45, 0xe6, 0xbf, 0x0a, 0x6c,
-	0xcc, 0x79, 0xe7, 0x7c, 0xb8, 0x6c, 0x1f, 0x79, 0x4a, 0x3b, 0xdf, 0xc3, 0x96, 0x1b, 0x05, 0xed,
-	0xe2, 0x63, 0x32, 0x23, 0x76, 0xee, 0xd9, 0xf2, 0x17, 0x67, 0xfc, 0xa1, 0x72, 0xae, 0xca, 0x6f,
-	0xd4, 0xbf, 0xab, 0x75, 0xfb, 0xc7, 0x61, 0xe7, 0x9f, 0xaa, 0x96, 0x02, 0x2e, 0x34, 0xf9, 0xb9,
-	0xf9, 0xe4, 0xff, 0x00, 0x00, 0x00, 0xff, 0xff, 0x97, 0x8b, 0x36, 0x13, 0xc3, 0x0a, 0x00, 0x00,
+	// 1153 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x56, 0x4d, 0x6f, 0x1a, 0x47,
+	0x18, 0x66, 0x81, 0x05, 0xf6, 0xc5, 0xc4, 0x74, 0xfc, 0xb5, 0x25, 0xa9, 0x6b, 0x6d, 0x22, 0x35,
+	0xea, 0x01, 0x24, 0x47, 0x55, 0xab, 0x44, 0x89, 0x6a, 0x60, 0x1d, 0x70, 0xe3, 0x05, 0x8d, 0x71,
+	0x3f, 0x7c, 0x41, 0xc3, 0x32, 0xb6, 0x37, 0x66, 0x3f, 0xb4, 0x3b, 0xb8, 0x75, 0xcf, 0x3d, 0xf5,
+	0xd4, 0x53, 0x8f, 0xad, 0xd4, 0x63, 0x4f, 0x3d, 0xf5, 0x3f, 0xf4, 0x3f, 0xf5, 0x50, 0xcd, 0xcc,
+	0x2e, 0x5f, 0x06, 0x3b, 0xb9, 0xf4, 0xc4, 0xbc, 0x33, 0xcf, 0xf3, 0xce, 0xcb, 0x33, 0xef, 0x3c,
+	0xb3, 0xb0, 0x19, 0x32, 0x12, 0x38, 0xb5, 0x90, 0x92, 0x11, 0x73, 0x5c, 0x5a, 0x0d, 0x42, 0x9f,
+	0xf9, 0x68, 0xdd, 0x23, 0x57, 0xc4, 0x25, 0xd5, 0x64, 0xba, 0xb2, 0x7b, 0xe1, 0xfb, 0x17, 0x23,
+	0x5a, 0x13, 0xcb, 0x83, 0xf1, 0x79, 0xed, 0xfb, 0x90, 0x04, 0x01, 0x0d, 0x23, 0x49, 0xa8, 0x7c,
+	0x7a, 0xe1, 0xb0, 0xcb, 0xf1, 0xa0, 0x6a, 0xfb, 0x6e, 0xed, 0x92, 0x86, 0xbe, 0x63, 0x8f, 0xc8,
+	0x20, 0xaa, 0xc9, 0x34, 0x35, 0xbe, 0x03, 0x09, 0x1c, 0x89, 0x35, 0xfe, 0xca, 0x41, 0xc1, 0xf4,
+	0xae, 0xe9, 0xc8, 0x0f, 0x28, 0x2a, 0x43, 0xc6, 0x76, 0x86, 0xba, 0xb2, 0xa7, 0x3c, 0xd5, 0x30,
+	0x1f, 0xa2, 0x2a, 0xa8, 0x34, 0x0c, 0xfd, 0x50, 0x4f, 0xef, 0x29, 0x4f, 0x8b, 0xfb, 0xdb, 0xd5,
+	0x85, 0x5a, 0xaa, 0x26, 0x5f, 0x6d, 0xa5, 0xb0, 0x84, 0x71, 0xbc, 0x4b, 0x98, 0x7d, 0xa9, 0x67,
+	0x56, 0xe0, 0x8f, 0xf9, 0x2a, 0xc7, 0x0b, 0x18, 0x3a, 0x80, 0x35, 0x31, 0xe8, 0xdb, 0x21, 0x25,
+	0x8c, 0xea, 0x59, 0x41, 0x7b, 0xb4, 0x9c, 0xd6, 0x10, 0x98, 0x56, 0x0a, 0x17, 0xdd, 0x69, 0x88,
+	0x5e, 0x00, 0xc8, 0x14, 0x43, 0xc2, 0x88, 0xae, 0x8a, 0x04, 0x95, 0xe5, 0x09, 0x9a, 0x84, 0x91,
+	0x56, 0x0a, 0x6b, 0x6e, 0x12, 0xa0, 0x16, 0xac, 0x4f, 0xc9, 0xfd, 0x88, 0x7a, 0x43, 0x3d, 0x27,
+	0x32, 0xec, 0xae, 0xce, 0x70, 0x42, 0xbd, 0x61, 0x2b, 0x85, 0x4b, 0xee, 0xec, 0xc4, 0xb4, 0x8c,
+	0xb7, 0xbe, 0xe3, 0xe9, 0xf9, 0xbb, 0xca, 0x38, 0xf2, 0x1d, 0x6f, 0x52, 0x06, 0x0f, 0xd0, 0x2b,
+	0x90, 0x7f, 0xa9, 0x3f, 0xa2, 0xe4, 0x9a, 0xea, 0x05, 0xc1, 0x7e, 0xb8, 0x9c, 0xfd, 0x86, 0x43,
+	0x5a, 0x29, 0x2c, 0xb7, 0x13, 0x11, 0xfa, 0x06, 0x36, 0x25, 0x3f, 0x08, 0x69, 0x44, 0x3d, 0x9b,
+	0xf6, 0xe9, 0x35, 0xf5, 0x98, 0xae, 0x89, 0x44, 0x8f, 0x97, 0x27, 0xea, 0xc6, 0x58, 0x93, 0x43,
+	0x5b, 0x29, 0x8c, 0xdc, 0x5b, 0xb3, 0xe8, 0x10, 0x4a, 0x9e, 0xcf, 0x9c, 0x73, 0xc7, 0x26, 0xcc,
+	0xf1, 0xbd, 0x48, 0x87, 0x15, 0xea, 0x58, 0xb3, 0x28, 0xae, 0xce, 0x1c, 0x0d, 0x3d, 0x86, 0x4c,
+	0x18, 0xd8, 0x7a, 0x51, 0xb0, 0xd7, 0x13, 0x36, 0x6f, 0x43, 0x1c, 0xd8, 0xad, 0x14, 0xe6, 0xab,
+	0x5c, 0x85, 0x88, 0x85, 0x94, 0xb8, 0xf2, 0x28, 0xd7, 0x56, 0xa8, 0x70, 0x22, 0x30, 0xf1, 0x59,
+	0x42, 0x34, 0x89, 0xd0, 0x19, 0x6c, 0xc5, 0xfc, 0x05, 0x19, 0x4a, 0x22, 0xd3, 0x93, 0x15, 0x99,
+	0x16, 0x75, 0xd8, 0x88, 0x6e, 0x4f, 0xd7, 0x35, 0xc8, 0xbb, 0x34, 0x8a, 0xc8, 0x05, 0x35, 0xfe,
+	0x4d, 0x83, 0x2a, 0xda, 0x1e, 0x21, 0xc8, 0xda, 0xfe, 0x90, 0x8a, 0x0b, 0xa3, 0x62, 0x31, 0x46,
+	0xfa, 0x04, 0x28, 0xee, 0x8c, 0x86, 0x93, 0x10, 0xbd, 0x84, 0xbc, 0xed, 0x7b, 0x8c, 0xfe, 0xc0,
+	0xf4, 0xcc, 0x5e, 0x66, 0xe9, 0xb9, 0x88, 0xb4, 0xd5, 0x86, 0x44, 0x99, 0x1e, 0x0b, 0x6f, 0x70,
+	0xc2, 0xa9, 0x3c, 0x87, 0xb5, 0xd9, 0x05, 0x7e, 0x59, 0xaf, 0xe8, 0x4d, 0x72, 0x59, 0xaf, 0xe8,
+	0x0d, 0xda, 0x04, 0xf5, 0x9a, 0x8c, 0xc6, 0xc9, 0xc6, 0x32, 0x78, 0x9e, 0xfe, 0x42, 0x31, 0xfe,
+	0x51, 0x20, 0xdb, 0xe0, 0xd5, 0x6d, 0xc1, 0x07, 0xf8, 0xd4, 0xea, 0xb5, 0x8f, 0xcd, 0xbe, 0xf9,
+	0x6d, 0xc3, 0xec, 0xf6, 0xda, 0x1d, 0xab, 0x9c, 0x42, 0x3a, 0x6c, 0x9e, 0x5a, 0xd8, 0x6c, 0x74,
+	0x5e, 0x5b, 0xed, 0x33, 0xb3, 0xd9, 0xef, 0x1e, 0x7c, 0xf7, 0xa6, 0x73, 0xd0, 0x2c, 0x2b, 0x68,
+	0x03, 0xd6, 0x8f, 0xdb, 0x27, 0x27, 0x6d, 0xeb, 0xf5, 0x64, 0x32, 0x8d, 0x4a, 0xa0, 0xd5, 0x0f,
+	0x9a, 0xfd, 0xb6, 0xd5, 0x3d, 0xed, 0x95, 0x33, 0x02, 0x73, 0xd0, 0x6b, 0xb4, 0xfa, 0x56, 0xa7,
+	0xd7, 0x3f, 0xec, 0x9c, 0x5a, 0xcd, 0x72, 0x16, 0xed, 0xc0, 0x86, 0x9c, 0x3c, 0xea, 0xb4, 0xad,
+	0x3e, 0x36, 0x8f, 0xcc, 0x46, 0xcf, 0x6c, 0x96, 0x55, 0xb4, 0x0b, 0x95, 0xa4, 0x84, 0xc3, 0x53,
+	0xab, 0xc1, 0x2b, 0x98, 0x21, 0xe6, 0x96, 0xae, 0x4f, 0x6b, 0xcd, 0x1b, 0x3f, 0xa5, 0x41, 0x15,
+	0xfd, 0x8b, 0x3e, 0x84, 0x82, 0xec, 0xfa, 0x89, 0x67, 0xe5, 0x45, 0xdc, 0x1e, 0xa2, 0x27, 0x50,
+	0x22, 0x63, 0x76, 0xe9, 0x87, 0x0e, 0x23, 0xcc, 0xb9, 0x96, 0x92, 0x14, 0xf0, 0xfc, 0x24, 0xda,
+	0x07, 0x75, 0x44, 0x06, 0x74, 0x14, 0xbb, 0xd5, 0xa3, 0xaa, 0x34, 0xd6, 0x6a, 0x62, 0xac, 0xbc,
+	0x41, 0x1c, 0xef, 0xe2, 0x6b, 0xae, 0x23, 0x96, 0x50, 0x7e, 0xe6, 0x91, 0xf3, 0xa3, 0x74, 0x2a,
+	0x15, 0x8b, 0x31, 0x7a, 0x09, 0x5a, 0xd2, 0x71, 0x91, 0xae, 0x8a, 0xb3, 0xfd, 0xf8, 0x9e, 0x66,
+	0xc3, 0x53, 0x06, 0x7a, 0x06, 0xd9, 0x88, 0x8e, 0xce, 0x63, 0xe7, 0xb9, 0x97, 0x29, 0xc0, 0x46,
+	0x09, 0x8a, 0x33, 0xa6, 0x68, 0xfc, 0xa2, 0x80, 0x36, 0x71, 0xa8, 0xbb, 0x94, 0x79, 0x01, 0x85,
+	0x64, 0xe7, 0xd8, 0xd4, 0xef, 0xdd, 0x70, 0x42, 0x40, 0x3b, 0x90, 0xf7, 0x83, 0xbe, 0xe8, 0x79,
+	0x2e, 0x59, 0x06, 0xe7, 0xfc, 0x40, 0xf4, 0x15, 0x82, 0xac, 0xb8, 0xb3, 0x5c, 0x95, 0x35, 0x2c,
+	0xc6, 0xc6, 0xaf, 0x0a, 0x94, 0xe6, 0x4c, 0xf3, 0xae, 0xb2, 0x66, 0x32, 0xa7, 0x97, 0x66, 0xce,
+	0x4c, 0x33, 0xcf, 0xeb, 0x9d, 0x7d, 0x5f, 0xbd, 0x8d, 0xc3, 0x58, 0x2a, 0x61, 0xbd, 0x0f, 0x17,
+	0x6b, 0x6a, 0xa5, 0xa6, 0x55, 0x6d, 0x83, 0xca, 0xfc, 0x2b, 0xea, 0xc9, 0x1b, 0xc5, 0x9f, 0x2d,
+	0x11, 0xd6, 0xb3, 0x90, 0x76, 0x86, 0xc6, 0x27, 0x00, 0x53, 0x47, 0xbe, 0xe3, 0xcf, 0x19, 0xbf,
+	0x2b, 0x80, 0x6e, 0x5b, 0xee, 0x5d, 0x72, 0x7c, 0x06, 0x2a, 0x7f, 0x47, 0x22, 0x3d, 0xfd, 0x6e,
+	0xff, 0x4e, 0xa2, 0xd1, 0xe7, 0x90, 0x13, 0x2f, 0x48, 0x14, 0x3b, 0xcc, 0xbd, 0xbc, 0x18, 0x6e,
+	0x74, 0xa0, 0x34, 0xe7, 0xe0, 0xe8, 0xd5, 0xa2, 0xf1, 0x2b, 0x22, 0xa1, 0x3e, 0x6b, 0xdd, 0xb3,
+	0x8c, 0x05, 0xc3, 0x37, 0x46, 0x90, 0x93, 0x5b, 0xf1, 0x03, 0x74, 0x67, 0x4c, 0xd2, 0x8d, 0x4d,
+	0x32, 0x1a, 0x0f, 0xde, 0x52, 0x9b, 0x25, 0x26, 0x19, 0x87, 0x68, 0x17, 0x60, 0x48, 0x23, 0x3b,
+	0x74, 0x02, 0xe6, 0x87, 0xe2, 0xd0, 0x35, 0x3c, 0x33, 0xc3, 0x3d, 0x4e, 0x5e, 0xd9, 0xac, 0xf4,
+	0x38, 0x11, 0x18, 0x3f, 0x2b, 0x00, 0xd3, 0x67, 0x01, 0xd5, 0x20, 0x27, 0x3d, 0x5c, 0x6c, 0x5a,
+	0xdc, 0xdf, 0x59, 0x21, 0x03, 0x8e, 0x61, 0x5c, 0x37, 0xfe, 0xf6, 0xd3, 0xf0, 0x5d, 0xaf, 0x44,
+	0x0c, 0x9f, 0xeb, 0x4e, 0x2d, 0xee, 0xfb, 0xdf, 0x14, 0x78, 0x30, 0x0f, 0xe7, 0xdd, 0x3d, 0x8e,
+	0x68, 0x38, 0x3d, 0xe8, 0x1c, 0x0f, 0xdb, 0x43, 0xf4, 0x11, 0x40, 0x44, 0xa3, 0xc8, 0xf1, 0x3d,
+	0xbe, 0x26, 0xb5, 0xd0, 0xe2, 0x99, 0xf6, 0x10, 0x55, 0xa0, 0xc0, 0x81, 0x1e, 0x71, 0x69, 0xbc,
+	0xc5, 0x24, 0x46, 0x7b, 0x50, 0xe4, 0xdf, 0x7c, 0x4e, 0xc4, 0xc4, 0x5d, 0xce, 0x0a, 0x83, 0x9b,
+	0x9d, 0x42, 0xdb, 0x5c, 0x06, 0xc2, 0xc6, 0x91, 0xf8, 0x2a, 0xd2, 0x70, 0x1c, 0x19, 0x7f, 0x2b,
+	0xb0, 0xb1, 0xe4, 0xe9, 0x7b, 0x7f, 0xd9, 0xfe, 0xe7, 0x2e, 0xad, 0x7f, 0x09, 0x5b, 0xb6, 0xef,
+	0x56, 0xa7, 0xdf, 0xb4, 0x31, 0xb1, 0xfe, 0xc0, 0x12, 0xbf, 0x38, 0xe6, 0x77, 0x95, 0x33, 0x55,
+	0x7c, 0x4a, 0xff, 0x91, 0xce, 0x5a, 0x5f, 0x75, 0xeb, 0x7f, 0xa6, 0x73, 0x12, 0x30, 0xc8, 0x09,
+	0x6b, 0x7f, 0xf6, 0x5f, 0x00, 0x00, 0x00, 0xff, 0xff, 0x3c, 0xab, 0xb4, 0x6f, 0x6a, 0x0b, 0x00,
+	0x00,
 }
diff --git a/rtapi/realtime.proto b/rtapi/realtime.proto
index cfc365fbc..c058ceeb8 100644
--- a/rtapi/realtime.proto
+++ b/rtapi/realtime.proto
@@ -19,6 +19,7 @@ syntax = "proto3";
 
 package nakama.realtime;
 
+import "google/protobuf/wrappers.proto";
 import "github.com/heroiclabs/nakama/api/api.proto";
 
 option go_package = "rtapi";
@@ -76,10 +77,12 @@ message Error {
     BAD_INPUT = 3;
     // The match id was not found.
     MATCH_NOT_FOUND = 4;
+    // The match join was rejected.
+    MATCH_JOIN_REJECTED = 5;
     // The runtime function does not exist on the server.
-    RUNTIME_FUNCTION_NOT_FOUND = 5;
+    RUNTIME_FUNCTION_NOT_FOUND = 6;
     // The runtime function executed with an error.
-    RUNTIME_FUNCTION_EXCEPTION = 6;
+    RUNTIME_FUNCTION_EXCEPTION = 7;
   }
 
   // The error code which should be one of "Error.Code" enums.
@@ -92,9 +95,18 @@ message Error {
 
 // A realtime match.
 message Match {
+  // The match unique ID.
   string match_id = 1;
-  repeated StreamPresence presences = 2;
-  StreamPresence self = 3;
+  // True if it's an server-managed authoritative match, false otherwise.
+  bool authoritative = 2;
+  // Match label, if any.
+  google.protobuf.StringValue label = 3;
+  // The number of users currently in the match.
+  int32 size = 4;
+  // The users currently in the match.
+  repeated StreamPresence presences = 5;
+  // A reference to the current user's presence in the match.
+  StreamPresence self = 6;
 }
 
 // Create a new realtime match.
@@ -102,37 +114,51 @@ message MatchCreate {}
 
 // Realtime match data received from the server.
 message MatchData {
+  // The match unique ID.
   string match_id = 1;
+  // A reference to the user presence that sent this data, if any.
   StreamPresence presence = 2;
+  // Op code value.
   int64 op_code = 3;
+  // Data payload, if any.
   bytes data = 4;
 }
 
 // Send realtime match data to the server.
 message MatchDataSend {
+  // The match unique ID.
   string match_id = 1;
+  // Op code value.
   int64 op_code = 2;
+  // Data payload, if any.
   bytes data = 3;
+  // List of presences in the match to deliver to, if filtering is required. Otherwise deliver to everyone in the match.
   repeated StreamPresence presences = 4;
 }
 
 // Join an existing realtime match.
 message MatchJoin {
   oneof id {
+    // The match unique ID.
     string match_id = 1;
+    // A matchmaking result token.
     string token = 2;
   }
 }
 
 // Leave a realtime match.
 message MatchLeave {
+  // The match unique ID.
   string match_id = 1;
 }
 
 // A set of joins and leaves on a particular realtime match.
 message MatchPresenceEvent {
+  // The match unique ID.
   string match_id = 1;
+  // User presences that have just joined the match.
   repeated StreamPresence joins = 2;
+  // User presences that have just left the match.
   repeated StreamPresence leaves = 3;
 }
 
diff --git a/server/api.go b/server/api.go
index 06df1c73e..88f52e44e 100644
--- a/server/api.go
+++ b/server/api.go
@@ -51,29 +51,31 @@ type ApiServer struct {
 	logger            *zap.Logger
 	db                *sql.DB
 	config            Config
-	runtimePool       *RuntimePool
+	socialClient      *social.Client
+	matchRegistry     MatchRegistry
 	tracker           Tracker
 	router            MessageRouter
-	socialClient      *social.Client
+	runtimePool       *RuntimePool
 	grpcServer        *grpc.Server
 	grpcGatewayServer *http.Server
 }
 
-func StartApiServer(logger *zap.Logger, db *sql.DB, jsonpbMarshaler *jsonpb.Marshaler, jsonpbUnmarshaler *jsonpb.Unmarshaler, config Config, socialClient *social.Client, registry *SessionRegistry, tracker Tracker, router MessageRouter, pipeline *pipeline, runtimePool *RuntimePool) *ApiServer {
+func StartApiServer(logger *zap.Logger, db *sql.DB, jsonpbMarshaler *jsonpb.Marshaler, jsonpbUnmarshaler *jsonpb.Unmarshaler, config Config, socialClient *social.Client, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, pipeline *pipeline, runtimePool *RuntimePool) *ApiServer {
 	grpcServer := grpc.NewServer(
 		grpc.StatsHandler(ocgrpc.NewServerStatsHandler()),
 		grpc.UnaryInterceptor(SecurityInterceptorFunc(logger, config)),
 	)
 
 	s := &ApiServer{
-		logger:       logger,
-		db:           db,
-		config:       config,
-		runtimePool:  runtimePool,
-		tracker:      tracker,
-		router:       router,
-		socialClient: socialClient,
-		grpcServer:   grpcServer,
+		logger:        logger,
+		db:            db,
+		config:        config,
+		socialClient:  socialClient,
+		matchRegistry: matchRegistry,
+		tracker:       tracker,
+		router:        router,
+		runtimePool:   runtimePool,
+		grpcServer:    grpcServer,
 	}
 
 	// Register and start GRPC server.
@@ -102,7 +104,7 @@ func StartApiServer(logger *zap.Logger, db *sql.DB, jsonpbMarshaler *jsonpb.Mars
 	grpcGatewayRouter := mux.NewRouter()
 	// Special case routes. Do NOT enable compression on WebSocket route, it results in "http: response.Write on hijacked connection" errors.
 	grpcGatewayRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }).Methods("GET")
-	grpcGatewayRouter.HandleFunc("/ws", NewSocketWsAcceptor(logger, config, registry, tracker, jsonpbMarshaler, jsonpbUnmarshaler, pipeline.processRequest))
+	grpcGatewayRouter.HandleFunc("/ws", NewSocketWsAcceptor(logger, config, sessionRegistry, tracker, jsonpbMarshaler, jsonpbUnmarshaler, pipeline))
 	// TODO restore when admin endpoints are available.
 	// grpcGatewayRouter.HandleFunc("/metrics", zpages.RpczHandler)
 	// grpcGatewayRouter.HandleFunc("/trace", zpages.TracezHandler)
diff --git a/server/api_authenticate.go b/server/api_authenticate.go
index d25720475..acb55d75d 100644
--- a/server/api_authenticate.go
+++ b/server/api_authenticate.go
@@ -256,7 +256,7 @@ func (s *ApiServer) AuthenticateSteam(ctx context.Context, in *api.AuthenticateS
 }
 
 func generateToken(config Config, userID, username string) string {
-	exp := time.Now().UTC().Add(time.Duration(config.GetSession().TokenExpiryMs) * time.Millisecond).Unix()
+	exp := time.Now().UTC().Add(time.Duration(config.GetSession().TokenExpirySec) * time.Second).Unix()
 	return generateTokenWithExpiry(config, userID, username, exp)
 }
 
diff --git a/server/api_match.go b/server/api_match.go
new file mode 100644
index 000000000..e96467054
--- /dev/null
+++ b/server/api_match.go
@@ -0,0 +1,50 @@
+// Copyright 2018 The Nakama Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"github.com/heroiclabs/nakama/api"
+	"golang.org/x/net/context"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+func (s *ApiServer) ListMatches(ctx context.Context, in *api.ListMatchesRequest) (*api.MatchList, error) {
+	limit := 1
+	if in.GetLimit() != nil {
+		if in.GetLimit().Value < 1 || in.GetLimit().Value > 100 {
+			return nil, status.Error(codes.InvalidArgument, "Invalid limit - limit must be between 1 and 100.")
+		}
+		limit = int(in.GetLimit().Value)
+	}
+
+	if in.Label != nil && (in.Authoritative != nil && !in.Authoritative.Value) {
+		return nil, status.Error(codes.InvalidArgument, "Label filtering is not supported for non-authoritative matches.")
+	}
+
+	if in.MinSize != nil && in.MinSize.Value < 0 {
+		return nil, status.Error(codes.InvalidArgument, "Minimum size must be 0 or above.")
+	}
+	if in.MaxSize != nil && in.MaxSize.Value < 0 {
+		return nil, status.Error(codes.InvalidArgument, "Maximum size must be 0 or above.")
+	}
+	if in.MinSize != nil && in.MaxSize != nil && in.MinSize.Value > in.MaxSize.Value {
+		return nil, status.Error(codes.InvalidArgument, "Maximum size must be greater than or equal to minimum size when both are specified.")
+	}
+
+	results := s.matchRegistry.ListMatches(limit, in.Authoritative, in.Label, in.MinSize, in.MaxSize)
+
+	return &api.MatchList{Matches: results}, nil
+}
diff --git a/server/api_rpc.go b/server/api_rpc.go
index 771464837..420722d0d 100644
--- a/server/api_rpc.go
+++ b/server/api_rpc.go
@@ -57,7 +57,7 @@ func (s *ApiServer) RpcFunc(ctx context.Context, in *api.Rpc) (*api.Rpc, error)
 		return nil, status.Error(codes.NotFound, "RPC function not found")
 	}
 
-	result, fnErr := runtime.InvokeFunctionRPC(lf, uid, username, expiry, "", in.Payload)
+	result, fnErr, code := runtime.InvokeFunctionRPC(lf, uid, username, expiry, "", in.Payload)
 	s.runtimePool.Put(runtime)
 	if fnErr != nil {
 		s.logger.Error("Runtime RPC function caused an error", zap.String("id", in.Id), zap.Error(fnErr))
@@ -72,9 +72,9 @@ func (s *ApiServer) RpcFunc(ctx context.Context, in *api.Rpc) (*api.Rpc, error)
 					msg = msgParts[0]
 				}
 			}
-			return nil, status.Error(codes.Aborted, msg)
+			return nil, status.Error(code, msg)
 		} else {
-			return nil, status.Error(codes.Aborted, fnErr.Error())
+			return nil, status.Error(code, fnErr.Error())
 		}
 	}
 
diff --git a/server/config.go b/server/config.go
index aa6ac7443..9e6b73e03 100644
--- a/server/config.go
+++ b/server/config.go
@@ -85,6 +85,14 @@ func ParseArgs(logger *zap.Logger, args []string) Config {
 		logger.Fatal("Could not parse command line arguments", zap.Error(err))
 	}
 
+	// Fail fast on invalid values.
+	if l := len(mainConfig.Name); l < 1 || l > 16 {
+		logger.Fatal("Name must be 1-16 characters", zap.String("param", "name"))
+	}
+	if mainConfig.GetSocket().PingPeriodMs >= mainConfig.GetSocket().PongWaitMs {
+		logger.Fatal("Ping period value must be less than pong wait value", zap.Int("socket.ping_period_ms", mainConfig.GetSocket().PingPeriodMs), zap.Int("socket.pong_wait_ms", mainConfig.GetSocket().PongWaitMs))
+	}
+
 	// If the runtime path is not overridden, set it to `datadir/modules`.
 	if mainConfig.GetRuntime().Path == "" {
 		mainConfig.GetRuntime().Path = filepath.Join(mainConfig.GetDataDir(), "modules")
@@ -105,15 +113,15 @@ func ParseArgs(logger *zap.Logger, args []string) Config {
 }
 
 type config struct {
-	Name     string          `yaml:"name" json:"name" usage:"Nakama server’s node name - must be unique"`
+	Name     string          `yaml:"name" json:"name" usage:"Nakama server’s node name - must be unique."`
 	Config   string          `yaml:"config" json:"config" usage:"The absolute file path to configuration YAML file."`
 	Datadir  string          `yaml:"data_dir" json:"data_dir" usage:"An absolute path to a writeable folder where Nakama will store its data."`
-	Log      *LogConfig      `yaml:"log" json:"log" usage:"Log levels and output"`
-	Session  *SessionConfig  `yaml:"session" json:"session" usage:"Session authentication settings"`
-	Socket   *SocketConfig   `yaml:"socket" json:"socket" usage:"Socket configurations"`
-	Database *DatabaseConfig `yaml:"database" json:"database" usage:"Database connection settings"`
-	Social   *SocialConfig   `yaml:"social" json:"social" usage:"Properties for social providers"`
-	Runtime  *RuntimeConfig  `yaml:"runtime" json:"runtime" usage:"Script Runtime properties"`
+	Log      *LogConfig      `yaml:"log" json:"log" usage:"Log levels and output."`
+	Session  *SessionConfig  `yaml:"session" json:"session" usage:"Session authentication settings."`
+	Socket   *SocketConfig   `yaml:"socket" json:"socket" usage:"Socket configuration."`
+	Database *DatabaseConfig `yaml:"database" json:"database" usage:"Database connection settings."`
+	Social   *SocialConfig   `yaml:"social" json:"social" usage:"Properties for social provider integrations."`
+	Runtime  *RuntimeConfig  `yaml:"runtime" json:"runtime" usage:"Script Runtime properties."`
 }
 
 // NewConfig constructs a Config struct which represents server settings, and populates it with default values.
@@ -167,13 +175,13 @@ func (c *config) GetRuntime() *RuntimeConfig {
 
 // LogConfig is configuration relevant to logging levels and output.
 type LogConfig struct {
-	// By default, log all messages with Warn and Error messages to a log file inside Data/Log/<name>.log file. The content will be in JSON.
+	// By default, log all messages with Info and higher levels to a log file inside Data/Log/<name>.log file. The content will be in JSON.
 	// if --log.verbose is passed, log messages with Debug and higher levels.
 	// if --log.stdout is passed, logs are only printed to stdout.
-	// In all cases, Error messages trigger the stacktrace to be dumped as well.
+	// In all cases, Error and Fatal messages trigger the stacktrace to be dumped as well.
 
-	Verbose bool `yaml:"verbose" json:"verbose" usage:"Turn verbose logging on"`
-	Stdout  bool `yaml:"stdout" json:"stdout" usage:"Log to stdout instead of file"`
+	Verbose bool `yaml:"verbose" json:"verbose" usage:"Turn verbose logging on."`
+	Stdout  bool `yaml:"stdout" json:"stdout" usage:"Log to stdout instead of file."`
 }
 
 // NewLogConfig creates a new LogConfig struct.
@@ -186,15 +194,15 @@ func NewLogConfig() *LogConfig {
 
 // SessionConfig is configuration relevant to the session.
 type SessionConfig struct {
-	EncryptionKey string `yaml:"encryption_key" json:"encryption_key" usage:"The encryption key used to produce the client token."`
-	TokenExpiryMs int64  `yaml:"token_expiry_ms" json:"token_expiry_ms" usage:"Token expiry in milliseconds."`
+	EncryptionKey  string `yaml:"encryption_key" json:"encryption_key" usage:"The encryption key used to produce the client token."`
+	TokenExpirySec int64  `yaml:"token_expiry_sec" json:"token_expiry_sec" usage:"Token expiry in seconds."`
 }
 
 // NewSessionConfig creates a new SessionConfig struct.
 func NewSessionConfig() *SessionConfig {
 	return &SessionConfig{
-		EncryptionKey: "defaultencryptionkey",
-		TokenExpiryMs: 60000,
+		EncryptionKey:  "defaultencryptionkey",
+		TokenExpirySec: 60,
 	}
 }
 
@@ -204,8 +212,8 @@ type SocketConfig struct {
 	Port                int    `yaml:"port" json:"port" usage:"The port for accepting connections from the client, listening on all interfaces."`
 	MaxMessageSizeBytes int64  `yaml:"max_message_size_bytes" json:"max_message_size_bytes" usage:"Maximum amount of data in bytes allowed to be read from the client socket per message."`
 	WriteWaitMs         int    `yaml:"write_wait_ms" json:"write_wait_ms" usage:"Time in milliseconds to wait for an ack from the client when writing data."`
-	PongWaitMs          int    `yaml:"pong_wait_ms" json:"pong_wait_ms" usage:"Time in milliseconds to wait for a pong message from the client after sending a ping."`
-	PingPeriodMs        int    `yaml:"ping_period_ms" json:"ping_period_ms" usage:"Time in milliseconds to wait between client ping messages. This value must be less than the pong_wait_ms."`
+	PongWaitMs          int    `yaml:"pong_wait_ms" json:"pong_wait_ms" usage:"Time in milliseconds to wait between pong messages received from the client."`
+	PingPeriodMs        int    `yaml:"ping_period_ms" json:"ping_period_ms" usage:"Time in milliseconds to wait between sending ping messages to the client. This value must be less than the pong_wait_ms."`
 	OutgoingQueueSize   int    `yaml:"outgoing_queue_size" json:"outgoing_queue_size" usage:"The maximum number of messages waiting to be sent to the client. If this is exceeded the client is considered too slow and will disconnect."`
 	SSLCertificate      string `yaml:"ssl_certificate" json:"ssl_certificate" usage:"Path to certificate file if you want the server to use SSL directly. Must also supply ssl_private_key"`
 	SSLPrivateKey       string `yaml:"ssl_private_key" json:"ssl_private_key" usage:"Path to private key file if you want the server to use SSL directly. Must also supply ssl_certificate"`
@@ -228,7 +236,7 @@ func NewSocketConfig() *SocketConfig {
 
 // DatabaseConfig is configuration relevant to the Database storage.
 type DatabaseConfig struct {
-	Addresses         []string `yaml:"address" json:"address" usage:"List of CockroachDB servers (username:password@address:port/dbname)"`
+	Addresses         []string `yaml:"address" json:"address" usage:"List of CockroachDB servers (username:password@address:port/dbname)."`
 	ConnMaxLifetimeMs int      `yaml:"conn_max_lifetime_ms" json:"conn_max_lifetime_ms" usage:"Time in milliseconds to reuse a database connection before the connection is killed and a new one is created."`
 	MaxOpenConns      int      `yaml:"max_open_conns" json:"max_open_conns" usage:"Maximum number of allowed open connections to the database."`
 	MaxIdleConns      int      `yaml:"max_idle_conns" json:"max_idle_conns" usage:"Maximum number of allowed open but unused connections to the database."`
@@ -246,7 +254,7 @@ func NewDatabaseConfig() *DatabaseConfig {
 
 // SocialConfig is configuration relevant to the social authentication providers.
 type SocialConfig struct {
-	Steam        *SocialConfigSteam  `yaml:"steam" json:"steam" usage:"Steam configuration"`
+	Steam *SocialConfigSteam `yaml:"steam" json:"steam" usage:"Steam configuration."`
 }
 
 // SocialConfigSteam is configuration relevant to Steam
@@ -268,8 +276,8 @@ func NewSocialConfig() *SocialConfig {
 // RuntimeConfig is configuration relevant to the Runtime Lua VM.
 type RuntimeConfig struct {
 	Environment map[string]interface{} `yaml:"env" json:"env"` // Not supported in FlagOverrides.
-	Path        string                 `yaml:"path" json:"path" usage:"Path of modules for the server to scan."`
-	HTTPKey     string                 `yaml:"http_key" json:"http_key" usage:"Runtime HTTP Invocation key"`
+	Path        string                 `yaml:"path" json:"path" usage:"Path for the server to scan for *.lua files."`
+	HTTPKey     string                 `yaml:"http_key" json:"http_key" usage:"Runtime HTTP Invocation key."`
 }
 
 // NewRuntimeConfig creates a new RuntimeConfig struct.
diff --git a/server/match_handler.go b/server/match_handler.go
new file mode 100644
index 000000000..df97d77c2
--- /dev/null
+++ b/server/match_handler.go
@@ -0,0 +1,723 @@
+// Copyright 2018 The Nakama Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"database/sql"
+	"fmt"
+	"github.com/heroiclabs/nakama/rtapi"
+	"github.com/heroiclabs/nakama/social"
+	"github.com/pkg/errors"
+	"github.com/satori/go.uuid"
+	"github.com/yuin/gopher-lua"
+	"go.uber.org/zap"
+	"sync"
+	"time"
+)
+
+const (
+	InputQueueSize = 128
+	CallQueueSize  = 128
+)
+
+type MatchDataMessage struct {
+	UserID    uuid.UUID
+	SessionID uuid.UUID
+	Username  string
+	Node      string
+	OpCode    int64
+	Data      []byte
+}
+
+type MatchHandler struct {
+	sync.Mutex
+	logger        *zap.Logger
+	matchRegistry MatchRegistry
+	tracker       Tracker
+	router        MessageRouter
+
+	// Identification not (directly) controlled by match init.
+	ID     uuid.UUID
+	Node   string
+	IDStr  string
+	Stream PresenceStream
+
+	// Internal state.
+	tick          lua.LNumber
+	vm            *lua.LState
+	initFn        lua.LValue
+	joinAttemptFn lua.LValue
+	leaveFn       lua.LValue
+	loopFn        lua.LValue
+	ctx           *lua.LTable
+	dispatcher    *lua.LTable
+
+	// Control elements.
+	inputCh chan *MatchDataMessage
+	ticker  *time.Ticker
+	callCh  chan func(*MatchHandler)
+	stopCh  chan struct{}
+	stopped bool
+
+	// Immutable configuration set by match init.
+	Label string
+	Rate  int
+
+	// Match state.
+	state lua.LValue
+}
+
+func NewMatchHandler(logger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, stdLibs map[string]lua.LGFunction, once *sync.Once, id uuid.UUID, node string, name string, params interface{}) (*MatchHandler, error) {
+	// Set up the Lua VM that will handle this match.
+	vm := lua.NewState(lua.Options{
+		CallStackSize:       1024,
+		RegistrySize:        1024,
+		SkipOpenLibs:        true,
+		IncludeGoStackTrace: true,
+	})
+	for name, lib := range stdLibs {
+		vm.Push(vm.NewFunction(lib))
+		vm.Push(lua.LString(name))
+		vm.Call(1, 0)
+	}
+	nakamaModule := NewNakamaModule(logger, db, config, socialClient, vm, sessionRegistry, matchRegistry, tracker, router, once, nil)
+	vm.PreloadModule("nakama", nakamaModule.Loader)
+
+	// Create the context to be used throughout this match.
+	ctx := vm.CreateTable(6, 6)
+	ctx.RawSetString(__CTX_ENV, ConvertMap(vm, config.GetRuntime().Environment))
+	ctx.RawSetString(__CTX_MODE, lua.LString(Match.String()))
+	ctx.RawSetString(__CTX_MATCH_ID, lua.LString(fmt.Sprintf("%v:%v", id.String(), node)))
+	ctx.RawSetString(__CTX_MATCH_NODE, lua.LString(node))
+
+	// Require the match module to load it (and its dependencies) and get its returned value.
+	req := vm.GetGlobal("require").(*lua.LFunction)
+	err := vm.GPCall(req.GFunction, lua.LString(name))
+	if err != nil {
+		return nil, fmt.Errorf("error loading match module: %v", err.Error())
+	}
+
+	// Extract the expected function references.
+	var tab *lua.LTable
+	if t := vm.Get(-1); t.Type() != lua.LTTable {
+		return nil, errors.New("match module must return a table containing the match callback functions")
+	} else {
+		tab = t.(*lua.LTable)
+	}
+	initFn := tab.RawGet(lua.LString("match_init"))
+	if initFn.Type() != lua.LTFunction {
+		return nil, errors.New("match_init not found or not a function")
+	}
+	joinAttemptFn := tab.RawGet(lua.LString("match_join_attempt"))
+	if joinAttemptFn.Type() != lua.LTFunction {
+		return nil, errors.New("match_join_attempt not found or not a function")
+	}
+	leaveFn := tab.RawGet(lua.LString("match_leave"))
+	if leaveFn.Type() != lua.LTFunction {
+		return nil, errors.New("match_leave not found or not a function")
+	}
+	loopFn := tab.RawGet(lua.LString("match_loop"))
+	if loopFn.Type() != lua.LTFunction {
+		return nil, errors.New("match_loop not found or not a function")
+	}
+
+	// Run the match_init sequence.
+	vm.Push(LSentinel)
+	vm.Push(initFn)
+	vm.Push(ctx)
+	if params == nil {
+		vm.Push(lua.LNil)
+	} else {
+		vm.Push(convertValue(vm, params))
+	}
+
+	err = vm.PCall(2, lua.MultRet, nil)
+	if err != nil {
+		return nil, fmt.Errorf("error running match_init: %v", err.Error())
+	}
+
+	// Extract desired label.
+	label := vm.Get(-1)
+	if label.Type() == LTSentinel {
+		return nil, errors.New("match_init returned unexpected third value, must be a label string")
+	} else if label.Type() != lua.LTString {
+		return nil, errors.New("match_init returned unexpected third value, must be a label string")
+	}
+	vm.Pop(1)
+
+	labelStr := label.String()
+	if len(labelStr) > 256 {
+		return nil, errors.New("match_init returned invalid label, must be 256 bytes or less")
+	}
+
+	// Extract desired tick rate.
+	rate := vm.Get(-1)
+	if rate.Type() == LTSentinel {
+		return nil, errors.New("match_init returned unexpected second value, must be a tick rate number")
+	} else if rate.Type() != lua.LTNumber {
+		return nil, errors.New("match_init returned unexpected second value, must be a tick rate number")
+	}
+	vm.Pop(1)
+
+	rateInt := int(rate.(lua.LNumber))
+	if rateInt > 30 || rateInt < 1 {
+		return nil, errors.New("match_init returned invalid tick rate, must be between 1 and 30")
+	}
+
+	// Extract initial state.
+	state := vm.Get(-1)
+	if state.Type() == LTSentinel {
+		return nil, errors.New("match_init returned unexpected first value, must be a state")
+	}
+	vm.Pop(1)
+
+	// Drop the sentinel value from the stack.
+	if sentinel := vm.Get(-1); sentinel.Type() != LTSentinel {
+		return nil, errors.New("match_init returned too many arguments, must be: state, tick rate number, label string")
+	}
+	vm.Pop(1)
+
+	// Add context values only available after match_init completes.
+	ctx.RawSetString(__CTX_MATCH_LABEL, label)
+	ctx.RawSetString(__CTX_MATCH_TICK_RATE, rate)
+
+	// Construct the match.
+	mh := &MatchHandler{
+		logger:        logger.With(zap.String("mid", id.String())),
+		matchRegistry: matchRegistry,
+		tracker:       tracker,
+		router:        router,
+
+		ID:    id,
+		Node:  node,
+		IDStr: fmt.Sprintf("%v:%v", id.String(), node),
+		Stream: PresenceStream{
+			Mode:    StreamModeMatchAuthoritative,
+			Subject: id,
+			Label:   node,
+		},
+
+		tick:          lua.LNumber(0),
+		vm:            vm,
+		initFn:        initFn,
+		joinAttemptFn: joinAttemptFn,
+		leaveFn:       leaveFn,
+		loopFn:        loopFn,
+		ctx:           ctx,
+		// Dispatcher below.
+
+		inputCh: make(chan *MatchDataMessage, InputQueueSize),
+		// Ticker below.
+		callCh:  make(chan func(mh *MatchHandler), CallQueueSize),
+		stopCh:  make(chan struct{}),
+		stopped: false,
+
+		Label: labelStr,
+		Rate:  rateInt,
+
+		state: state,
+	}
+
+	// Set up the dispatcher that exposes control functions to the match loop.
+	mh.dispatcher = vm.SetFuncs(vm.CreateTable(2, 2), map[string]lua.LGFunction{
+		"broadcast_message": mh.broadcastMessage,
+		"match_kick":        mh.matchKick,
+	})
+
+	// Set up the ticker that governs the match loop.
+	mh.ticker = time.NewTicker(time.Second / time.Duration(mh.Rate))
+
+	// Continuously run queued actions until the match stops.
+	go func() {
+		for {
+			select {
+			case <-mh.stopCh:
+				// Match has been stopped.
+				return
+			case <-mh.ticker.C:
+				// Tick, queue a match loop invocation.
+				if !mh.QueueCall(loop) {
+					return
+				}
+			case call := <-mh.callCh:
+				// An invocation to one of the match functions.
+				call(mh)
+			}
+		}
+	}()
+
+	mh.logger.Debug("Match started")
+
+	return mh, nil
+}
+
+// Used when an internal match process (or error) requires it to stop.
+func (mh *MatchHandler) Stop() {
+	mh.Close()
+	mh.matchRegistry.RemoveMatch(mh.ID, mh.Stream)
+}
+
+// Used when the match is closed externally.
+func (mh *MatchHandler) Close() {
+	mh.Lock()
+	if mh.stopped {
+		mh.Unlock()
+		return
+	}
+	mh.stopped = true
+	mh.Unlock()
+	close(mh.stopCh)
+	mh.ticker.Stop()
+}
+
+func (mh *MatchHandler) QueueCall(f func(*MatchHandler)) bool {
+	select {
+	case mh.callCh <- f:
+		return true
+	default:
+		// Match call queue is full, the handler isn't processing fast enough.
+		mh.logger.Warn("Match handler call processing too slow, closing match")
+		mh.Stop()
+		return false
+	}
+}
+
+func (mh *MatchHandler) QueueData(m *MatchDataMessage) {
+	select {
+	case mh.inputCh <- m:
+		return
+	default:
+		// Match input queue is full, the handler isn't processing fast enough or there's too much incoming data.
+		mh.logger.Warn("Match handler data processing too slow, dropping data message")
+		return
+	}
+}
+
+func loop(mh *MatchHandler) {
+	mh.Lock()
+	if mh.stopped {
+		mh.Unlock()
+		return
+	}
+	mh.Unlock()
+
+	// Drain the input queue into a Lua table.
+	size := len(mh.inputCh)
+	input := mh.vm.CreateTable(size, size)
+	for i := 1; i <= size; i++ {
+		msg := <-mh.inputCh
+
+		presence := mh.vm.CreateTable(4, 4)
+		presence.RawSetString("UserId", lua.LString(msg.UserID.String()))
+		presence.RawSetString("SessionId", lua.LString(msg.SessionID.String()))
+		presence.RawSetString("Username", lua.LString(msg.Username))
+		presence.RawSetString("Node", lua.LString(msg.Node))
+
+		in := mh.vm.CreateTable(3, 3)
+		in.RawSetString("Sender", presence)
+		in.RawSetString("OpCode", lua.LNumber(msg.OpCode))
+		if msg.Data != nil {
+			in.RawSetString("Data", lua.LString(msg.Data))
+		} else {
+			in.RawSetString("Data", lua.LNil)
+		}
+
+		input.RawSetInt(i, in)
+	}
+
+	// Execute the match_loop call.
+	mh.vm.Push(LSentinel)
+	mh.vm.Push(mh.loopFn)
+	mh.vm.Push(mh.ctx)
+	mh.vm.Push(mh.dispatcher)
+	mh.vm.Push(mh.tick)
+	mh.vm.Push(mh.state)
+	mh.vm.Push(input)
+
+	err := mh.vm.PCall(5, lua.MultRet, nil)
+	if err != nil {
+		mh.Stop()
+		mh.logger.Warn("Stopping match after error from match_loop execution", zap.Int("tick", int(mh.tick)), zap.Error(err))
+		return
+	}
+
+	// Extract the resulting state.
+	state := mh.vm.Get(-1)
+	if state.Type() == lua.LTNil || state.Type() == LTSentinel {
+		mh.logger.Debug("Match loop returned nil or no state, stopping match")
+		mh.Stop()
+		return
+	}
+	mh.vm.Pop(1)
+	// Check for and remove the sentinel value, will fail if there are any extra return values.
+	if sentinel := mh.vm.Get(-1); sentinel.Type() != LTSentinel {
+		mh.logger.Warn("Match loop returned too many values, stopping match")
+		mh.Stop()
+		return
+	}
+	mh.vm.Pop(1)
+
+	mh.state = state
+	mh.tick++
+}
+
+func JoinAttempt(resultCh chan *MatchJoinResult, userID, sessionID uuid.UUID, username, node string) func(mh *MatchHandler) {
+	return func(mh *MatchHandler) {
+		mh.Lock()
+		if mh.stopped {
+			mh.Unlock()
+			resultCh <- &MatchJoinResult{Allow: false}
+			return
+		}
+		mh.Unlock()
+
+		presence := mh.vm.CreateTable(4, 4)
+		presence.RawSetString("UserId", lua.LString(userID.String()))
+		presence.RawSetString("SessionId", lua.LString(sessionID.String()))
+		presence.RawSetString("Username", lua.LString(username))
+		presence.RawSetString("Node", lua.LString(node))
+
+		// Execute the match_join_attempt call.
+		mh.vm.Push(LSentinel)
+		mh.vm.Push(mh.joinAttemptFn)
+		mh.vm.Push(mh.ctx)
+		mh.vm.Push(mh.dispatcher)
+		mh.vm.Push(mh.tick)
+		mh.vm.Push(mh.state)
+		mh.vm.Push(presence)
+
+		err := mh.vm.PCall(5, lua.MultRet, nil)
+		if err != nil {
+			mh.Stop()
+			mh.logger.Warn("Stopping match after error from match_join_attempt execution", zap.Int("tick", int(mh.tick)), zap.Error(err))
+			resultCh <- &MatchJoinResult{Allow: false}
+			return
+		}
+
+		// Extract the join attempt response.
+		allow := mh.vm.Get(-1)
+		if allow.Type() == LTSentinel {
+			mh.logger.Warn("Match join attempt returned too few values, stopping match - expected: state, join result boolean")
+			mh.Stop()
+			resultCh <- &MatchJoinResult{Allow: false}
+			return
+		} else if allow.Type() != lua.LTBool {
+			mh.logger.Warn("Match join attempt returned non-boolean join result, stopping match")
+			mh.Stop()
+			resultCh <- &MatchJoinResult{Allow: false}
+			return
+		}
+		mh.vm.Pop(1)
+		// Extract the resulting state.
+		state := mh.vm.Get(-1)
+		if state.Type() == lua.LTNil || state.Type() == LTSentinel {
+			mh.logger.Debug("Match join attempt returned nil or no state, stopping match")
+			mh.Stop()
+			resultCh <- &MatchJoinResult{Allow: false}
+			return
+		}
+		mh.vm.Pop(1)
+		// Check for and remove the sentinel value, will fail if there are any extra return values.
+		if sentinel := mh.vm.Get(-1); sentinel.Type() != LTSentinel {
+			mh.logger.Warn("Match join attempt returned too many values, stopping match")
+			mh.Stop()
+			resultCh <- &MatchJoinResult{Allow: false}
+			return
+		}
+		mh.vm.Pop(1)
+
+		mh.state = state
+		resultCh <- &MatchJoinResult{Allow: lua.LVAsBool(allow), Label: mh.Label}
+	}
+}
+
+func Leave(leaves []*MatchPresence) func(mh *MatchHandler) {
+	return func(mh *MatchHandler) {
+		mh.Lock()
+		if mh.stopped {
+			mh.Unlock()
+			return
+		}
+		mh.Unlock()
+
+		size := len(leaves)
+		presences := mh.vm.CreateTable(size, size)
+		for i, p := range leaves {
+			presence := mh.vm.CreateTable(4, 4)
+			presence.RawSetString("UserId", lua.LString(p.UserID.String()))
+			presence.RawSetString("SessionId", lua.LString(p.SessionID.String()))
+			presence.RawSetString("Username", lua.LString(p.Username))
+			presence.RawSetString("Node", lua.LString(p.Node))
+
+			presences.RawSetInt(i+1, presence)
+		}
+
+		// Execute the match_leave call.
+		mh.vm.Push(LSentinel)
+		mh.vm.Push(mh.leaveFn)
+		mh.vm.Push(mh.ctx)
+		mh.vm.Push(mh.dispatcher)
+		mh.vm.Push(mh.tick)
+		mh.vm.Push(mh.state)
+		mh.vm.Push(presences)
+
+		err := mh.vm.PCall(5, lua.MultRet, nil)
+		if err != nil {
+			mh.Stop()
+			mh.logger.Warn("Stopping match after error from match_leave execution", zap.Int("tick", int(mh.tick)), zap.Error(err))
+			return
+		}
+
+		// Extract the resulting state.
+		state := mh.vm.Get(-1)
+		if state.Type() == lua.LTNil || state.Type() == LTSentinel {
+			mh.logger.Debug("Match leave returned nil or no state, stopping match")
+			mh.Stop()
+			return
+		}
+		mh.vm.Pop(1)
+		// Check for and remove the sentinel value, will fail if there are any extra return values.
+		if sentinel := mh.vm.Get(-1); sentinel.Type() != LTSentinel {
+			mh.logger.Warn("Match leave returned too many values, stopping match")
+			mh.Stop()
+			return
+		}
+		mh.vm.Pop(1)
+
+		mh.state = state
+	}
+}
+
+func (mh *MatchHandler) broadcastMessage(l *lua.LState) int {
+	opCode := l.CheckInt64(1)
+
+	var dataBytes []byte
+	if data := l.Get(2); data.Type() != lua.LTNil {
+		if data.Type() != lua.LTString {
+			l.ArgError(2, "expects data to be a string or nil")
+			return 0
+		}
+		dataBytes = []byte(data.(lua.LString))
+	}
+
+	filter := l.OptTable(3, nil)
+	var presenceIDs []*PresenceID
+	if filter != nil {
+		fl := filter.Len()
+		if fl == 0 {
+			return 0
+		}
+		presenceIDs = make([]*PresenceID, 0, fl)
+		conversionError := false
+		filter.ForEach(func(_, p lua.LValue) {
+			pt, ok := p.(*lua.LTable)
+			if !ok {
+				conversionError = true
+				l.ArgError(1, "expects a valid set of presences")
+				return
+			}
+
+			presenceID := &PresenceID{}
+			pt.ForEach(func(k, v lua.LValue) {
+				switch k.String() {
+				case "SessionId":
+					sid, err := uuid.FromString(v.String())
+					if err != nil {
+						conversionError = true
+						l.ArgError(1, "expects each presence to have a valid SessionId")
+						return
+					}
+					presenceID.SessionID = sid
+				case "Node":
+					if v.Type() != lua.LTString {
+						conversionError = true
+						l.ArgError(1, "expects Node to be string")
+						return
+					}
+					presenceID.Node = v.String()
+				}
+			})
+			if presenceID.SessionID == uuid.Nil || presenceID.Node == "" {
+				conversionError = true
+				l.ArgError(1, "expects each presence to have a valid UserId, SessionId, and Node")
+				return
+			}
+			if conversionError {
+				return
+			}
+			presenceIDs = append(presenceIDs, presenceID)
+		})
+		if conversionError {
+			return 0
+		}
+	}
+
+	if presenceIDs != nil && len(presenceIDs) == 0 {
+		// Filter is empty, there are no requested message targets.
+		return 0
+	}
+
+	sender := l.OptTable(4, nil)
+	var presence *rtapi.StreamPresence
+	if sender != nil {
+		presence := &rtapi.StreamPresence{}
+		conversionError := false
+		sender.ForEach(func(k, v lua.LValue) {
+			switch k.String() {
+			case "UserId":
+				s := v.String()
+				_, err := uuid.FromString(s)
+				if err != nil {
+					conversionError = true
+					l.ArgError(4, "expects presence to have a valid UserId")
+					return
+				}
+				presence.UserId = s
+			case "SessionId":
+				s := v.String()
+				_, err := uuid.FromString(s)
+				if err != nil {
+					conversionError = true
+					l.ArgError(4, "expects presence to have a valid SessionId")
+					return
+				}
+				presence.SessionId = s
+			case "Username":
+				if v.Type() != lua.LTString {
+					conversionError = true
+					l.ArgError(4, "expects Username to be string")
+					return
+				}
+				presence.Username = v.String()
+			}
+		})
+		if presence.UserId == "" || presence.SessionId == "" || presence.Username == "" {
+			l.ArgError(4, "expects presence to have a valid UserId, SessionId, and Username")
+			return 0
+		}
+		if conversionError {
+			return 0
+		}
+	}
+
+	if presenceIDs != nil {
+		// Ensure specific presences actually exist to prevent sending bogus messages to arbitrary users.
+		actualPresenceIDs := mh.tracker.ListPresenceIDByStream(mh.Stream)
+		for i := 0; i < len(presenceIDs); i++ {
+			found := false
+			presenceID := presenceIDs[i]
+			for j := 0; j < len(actualPresenceIDs); j++ {
+				if actual := actualPresenceIDs[j]; presenceID.SessionID == actual.SessionID && presenceID.Node == actual.Node {
+					// If it matches, drop it.
+					actualPresenceIDs[j] = actualPresenceIDs[len(actualPresenceIDs)-1]
+					actualPresenceIDs = actualPresenceIDs[:len(actualPresenceIDs)-1]
+					found = true
+					break
+				}
+			}
+			if !found {
+				// If this presence wasn't in the filters, it's not needed.
+				presenceIDs[i] = presenceIDs[len(presenceIDs)-1]
+				presenceIDs = presenceIDs[:len(presenceIDs)-1]
+				i--
+			}
+		}
+		if len(presenceIDs) == 0 {
+			// None of the target presenceIDs existed in the list of match members.
+			return 0
+		}
+	}
+
+	msg := &rtapi.Envelope{Message: &rtapi.Envelope_MatchData{MatchData: &rtapi.MatchData{
+		MatchId:  mh.IDStr,
+		Presence: presence,
+		OpCode:   opCode,
+		Data:     dataBytes,
+	}}}
+
+	if presenceIDs == nil {
+		mh.router.SendToStream(mh.logger, mh.Stream, msg)
+	} else {
+		mh.router.SendToPresenceIDs(mh.logger, presenceIDs, msg)
+	}
+
+	return 0
+}
+
+func (mh *MatchHandler) matchKick(l *lua.LState) int {
+	input := l.OptTable(1, nil)
+	if input == nil {
+		return 0
+	}
+	size := input.Len()
+	if size == 0 {
+		return 0
+	}
+
+	presences := make([]*MatchPresence, 0, size)
+	conversionError := false
+	input.ForEach(func(_, p lua.LValue) {
+		pt, ok := p.(*lua.LTable)
+		if !ok {
+			conversionError = true
+			l.ArgError(1, "expects a valid set of presences")
+			return
+		}
+
+		presence := &MatchPresence{}
+		pt.ForEach(func(k, v lua.LValue) {
+			switch k.String() {
+			case "UserId":
+				uid, err := uuid.FromString(v.String())
+				if err != nil {
+					conversionError = true
+					l.ArgError(1, "expects each presence to have a valid UserId")
+					return
+				}
+				presence.UserID = uid
+			case "SessionId":
+				sid, err := uuid.FromString(v.String())
+				if err != nil {
+					conversionError = true
+					l.ArgError(1, "expects each presence to have a valid SessionId")
+					return
+				}
+				presence.SessionID = sid
+			case "Node":
+				if v.Type() != lua.LTString {
+					conversionError = true
+					l.ArgError(1, "expects Node to be string")
+					return
+				}
+				presence.Node = v.String()
+			}
+		})
+		if presence.UserID == uuid.Nil || presence.SessionID == uuid.Nil || presence.Node == "" {
+			conversionError = true
+			l.ArgError(1, "expects each presence to have a valid UserId, SessionId, and Node")
+			return
+		}
+		if conversionError {
+			return
+		}
+		presences = append(presences, presence)
+	})
+	if conversionError {
+		return 0
+	}
+
+	mh.matchRegistry.Kick(mh.Stream, presences)
+	return 0
+}
diff --git a/server/match_registry.go b/server/match_registry.go
new file mode 100644
index 000000000..f531cba13
--- /dev/null
+++ b/server/match_registry.go
@@ -0,0 +1,346 @@
+// Copyright 2018 The Nakama Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"database/sql"
+	"fmt"
+	"github.com/golang/protobuf/ptypes/wrappers"
+	"github.com/heroiclabs/nakama/api"
+	"github.com/heroiclabs/nakama/social"
+	"github.com/satori/go.uuid"
+	"github.com/yuin/gopher-lua"
+	"go.uber.org/zap"
+	"sync"
+	"time"
+)
+
+var (
+	MatchFilterValue = uint8(0)
+	MatchFilterPtr   = &MatchFilterValue
+
+	MatchFilterAny           = map[uint8]*uint8{StreamModeMatchRelayed: MatchFilterPtr, StreamModeMatchAuthoritative: MatchFilterPtr}
+	MatchFilterRelayed       = map[uint8]*uint8{StreamModeMatchRelayed: MatchFilterPtr}
+	MatchFilterAuthoritative = map[uint8]*uint8{StreamModeMatchAuthoritative: MatchFilterPtr}
+)
+
+type MatchPresence struct {
+	Node      string
+	UserID    uuid.UUID
+	SessionID uuid.UUID
+	Username  string
+}
+
+type MatchJoinResult struct {
+	Allow bool
+	Label string
+}
+
+type MatchRegistry interface {
+	// Create and start a new match, given a Lua module name.
+	NewMatch(name string, params interface{}) (*MatchHandler, error)
+	// Return a match handler by ID, only from the local node.
+	GetMatch(id uuid.UUID) *MatchHandler
+	// Remove a tracked match and ensure all its presences are cleaned up.
+	// Does not ensure the match process itself is no longer running, that must be handled separately.
+	RemoveMatch(id uuid.UUID, stream PresenceStream)
+	// List (and optionally filter) currently running matches.
+	// This can list across both authoritative and relayed matches.
+	ListMatches(limit int, authoritative *wrappers.BoolValue, label *wrappers.StringValue, minSize *wrappers.Int32Value, maxSize *wrappers.Int32Value) []*api.Match
+	// Stop the match registry and close all matches it's tracking.
+	Stop()
+
+	// Pass a user join attempt to a match handler. Returns if the match was found, if the join was accepted, and the match label.
+	Join(id uuid.UUID, node string, userID, sessionID uuid.UUID, username, fromNode string) (bool, bool, string)
+	// Notify a match handler that a user has left or disconnected.
+	// Expects that the caller has already determined the match is hosted on the current node.
+	Leave(id uuid.UUID, presences []*MatchPresence)
+	// Called by match handlers to request the removal fo a match participant.
+	Kick(stream PresenceStream, presences []*MatchPresence)
+	// Pass a data payload (usually from a user) to the appropriate match handler.
+	// Assumes that the data sender has already been validated as a match participant before this call.
+	SendData(id uuid.UUID, node string, userID, sessionID uuid.UUID, username, fromNode string, opCode int64, data []byte)
+}
+
+type LocalMatchRegistry struct {
+	sync.RWMutex
+	logger          *zap.Logger
+	db              *sql.DB
+	config          Config
+	socialClient    *social.Client
+	sessionRegistry *SessionRegistry
+	tracker         Tracker
+	router          MessageRouter
+	stdLibs         map[string]lua.LGFunction
+	modules         *sync.Map
+	once            *sync.Once
+	node            string
+	matches         map[uuid.UUID]*MatchHandler
+}
+
+func NewLocalMatchRegistry(logger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, sessionRegistry *SessionRegistry, tracker Tracker, router MessageRouter, stdLibs map[string]lua.LGFunction, once *sync.Once, node string) MatchRegistry {
+	return &LocalMatchRegistry{
+		logger:          logger,
+		db:              db,
+		config:          config,
+		socialClient:    socialClient,
+		sessionRegistry: sessionRegistry,
+		tracker:         tracker,
+		router:          router,
+		stdLibs:         stdLibs,
+		once:            once,
+		node:            node,
+		matches:         make(map[uuid.UUID]*MatchHandler),
+	}
+}
+
+func (r *LocalMatchRegistry) NewMatch(name string, params interface{}) (*MatchHandler, error) {
+	id := uuid.NewV4()
+	match, err := NewMatchHandler(r.logger, r.db, r.config, r.socialClient, r.sessionRegistry, r, r.tracker, r.router, r.stdLibs, r.once, id, r.node, name, params)
+	if err != nil {
+		return nil, err
+	}
+	r.Lock()
+	r.matches[id] = match
+	r.Unlock()
+	return match, nil
+}
+
+func (r *LocalMatchRegistry) GetMatch(id uuid.UUID) *MatchHandler {
+	var mh *MatchHandler
+	r.RLock()
+	mh = r.matches[id]
+	r.RUnlock()
+	return mh
+}
+
+func (r *LocalMatchRegistry) RemoveMatch(id uuid.UUID, stream PresenceStream) {
+	r.Lock()
+	delete(r.matches, id)
+	r.Unlock()
+	r.tracker.UntrackByStream(stream)
+}
+
+func (r *LocalMatchRegistry) ListMatches(limit int, authoritative *wrappers.BoolValue, label *wrappers.StringValue, minSize *wrappers.Int32Value, maxSize *wrappers.Int32Value) []*api.Match {
+	var modes map[uint8]*uint8
+	if authoritative == nil {
+		modes = MatchFilterAny
+	} else if authoritative.Value {
+		modes = MatchFilterAuthoritative
+	} else {
+		modes = MatchFilterRelayed
+	}
+
+	// Initial list of candidate matches.
+	matches := r.tracker.CountByStreamModeFilter(modes)
+
+	// Results.
+	results := make([]*api.Match, 0, limit)
+
+	// Track authoritative matches that have been checked already, if authoritative results are allowed.
+	var checked map[uuid.UUID]struct{}
+	if authoritative == nil || authoritative.Value {
+		checked = make(map[uuid.UUID]struct{})
+	}
+
+	// Maybe filter by label.
+	for stream, size := range matches {
+		if stream.Mode == StreamModeMatchRelayed {
+			if label != nil {
+				// Any label filter fails for relayed matches.
+				continue
+			}
+			if minSize != nil && minSize.Value > size {
+				// Too few users.
+				continue
+			}
+			if maxSize != nil && maxSize.Value < size {
+				// Too many users.
+				continue
+			}
+
+			matchID := fmt.Sprintf("%v:", stream.Subject.String())
+			results = append(results, &api.Match{
+				MatchId:       matchID,
+				Authoritative: false,
+				// No label.
+				Size: size,
+			})
+			if len(results) == limit {
+				break
+			}
+		} else if stream.Mode == StreamModeMatchAuthoritative {
+			// Authoritative matches that have already been checked.
+			checked[stream.Subject] = struct{}{}
+
+			if minSize != nil && minSize.Value > size {
+				// Too few users.
+				continue
+			}
+			if maxSize != nil && maxSize.Value < size {
+				// Too many users.
+				continue
+			}
+
+			mh := r.GetMatch(stream.Subject)
+			if mh == nil || (label != nil && label.Value != mh.Label) {
+				continue
+			}
+			results = append(results, &api.Match{
+				MatchId:       mh.IDStr,
+				Authoritative: true,
+				Label:         &wrappers.StringValue{Value: mh.Label},
+				Size:          size,
+			})
+			if len(results) == limit {
+				break
+			}
+		} else {
+			r.logger.Warn("Ignoring unknown stream mode in match listing operation", zap.Uint8("mode", stream.Mode))
+		}
+	}
+
+	// Return results here if:
+	// 1. We have enough results.
+	// or
+	// 2. Not enough results, but we're not allowed to return potentially empty authoritative matches.
+	if len(results) == limit || ((authoritative != nil && !authoritative.Value) || (minSize != nil && minSize.Value > 0)) {
+		return results
+	}
+
+	// Otherwise look for empty matches to help fulfil the request, but ensure no duplicates.
+	r.RLock()
+	for _, mh := range r.matches {
+		if _, ok := checked[mh.ID]; ok {
+			// Already checked and discarded this match for failing a filter, skip it.
+			continue
+		}
+		if label != nil && label.Value != mh.Label {
+			// Label mismatch.
+			continue
+		}
+		size := int32(r.tracker.CountByStream(mh.Stream))
+		if minSize != nil && minSize.Value > size {
+			// Too few users.
+			continue
+		}
+		if maxSize != nil && maxSize.Value < size {
+			// Too many users.
+			continue
+		}
+		results = append(results, &api.Match{
+			MatchId:       mh.IDStr,
+			Authoritative: true,
+			Label:         &wrappers.StringValue{Value: mh.Label},
+			Size:          size,
+		})
+		if len(results) == limit {
+			break
+		}
+	}
+	r.RUnlock()
+
+	return results
+}
+
+func (r *LocalMatchRegistry) Stop() {
+	r.Lock()
+	for id, mh := range r.matches {
+		mh.Close()
+		delete(r.matches, id)
+	}
+	r.Unlock()
+}
+
+func (r *LocalMatchRegistry) Join(id uuid.UUID, node string, userID, sessionID uuid.UUID, username, fromNode string) (bool, bool, string) {
+	if node != r.node {
+		return false, false, ""
+	}
+
+	var mh *MatchHandler
+	var ok bool
+	r.RLock()
+	mh, ok = r.matches[id]
+	r.RUnlock()
+	if !ok {
+		return false, false, ""
+	}
+
+	resultCh := make(chan *MatchJoinResult, 1)
+	if !mh.QueueCall(JoinAttempt(resultCh, userID, sessionID, username, fromNode)) {
+		// The match call queue was full, so will be closed and therefore can't be joined.
+		return true, false, ""
+	}
+
+	// Set up a limit to how long the call will wait, default is 10 seconds.
+	ticker := time.NewTicker(time.Second * 10)
+	select {
+	case <-ticker.C:
+		ticker.Stop()
+		// The join attempt has timed out, join is assumed to be rejected.
+		return true, false, ""
+	case r := <-resultCh:
+		ticker.Stop()
+		// The join attempt has returned a result.
+		return true, r.Allow, r.Label
+	}
+}
+
+func (r *LocalMatchRegistry) Leave(id uuid.UUID, presences []*MatchPresence) {
+	var mh *MatchHandler
+	var ok bool
+	r.RLock()
+	mh, ok = r.matches[id]
+	r.RUnlock()
+	if !ok {
+		return
+	}
+
+	// Doesn't matter if the call queue was full here. If the match is being closed then leaves don't matter anyway.
+	mh.QueueCall(Leave(presences))
+}
+
+func (r *LocalMatchRegistry) Kick(stream PresenceStream, presences []*MatchPresence) {
+	for _, presence := range presences {
+		if presence.Node != r.node {
+			continue
+		}
+		r.tracker.Untrack(presence.SessionID, stream, presence.UserID)
+	}
+}
+
+func (r *LocalMatchRegistry) SendData(id uuid.UUID, node string, userID, sessionID uuid.UUID, username, fromNode string, opCode int64, data []byte) {
+	if node != r.node {
+		return
+	}
+
+	var mh *MatchHandler
+	var ok bool
+	r.RLock()
+	mh, ok = r.matches[id]
+	r.RUnlock()
+	if !ok {
+		return
+	}
+
+	mh.QueueData(&MatchDataMessage{
+		UserID:    userID,
+		SessionID: sessionID,
+		Username:  username,
+		Node:      node,
+		OpCode:    opCode,
+		Data:      data,
+	})
+}
diff --git a/server/message_router.go b/server/message_router.go
index 80d291b09..dd8e42bce 100644
--- a/server/message_router.go
+++ b/server/message_router.go
@@ -16,55 +16,56 @@ package server
 
 import (
 	"github.com/golang/protobuf/jsonpb"
-	"github.com/golang/protobuf/proto"
+	"github.com/heroiclabs/nakama/rtapi"
 	"go.uber.org/zap"
 )
 
 // MessageRouter is responsible for sending a message to a list of presences or to an entire stream.
 type MessageRouter interface {
-	SendToPresences(*zap.Logger, []Presence, proto.Message)
-	SendToStream(*zap.Logger, PresenceStream, proto.Message)
+	SendToPresenceIDs(*zap.Logger, []*PresenceID, *rtapi.Envelope)
+	SendToStream(*zap.Logger, PresenceStream, *rtapi.Envelope)
 }
 
 type LocalMessageRouter struct {
 	jsonpbMarshaler *jsonpb.Marshaler
-	registry        *SessionRegistry
+	sessionRegistry *SessionRegistry
 	tracker         Tracker
 }
 
-func NewLocalMessageRouter(registry *SessionRegistry, tracker Tracker, jsonpbMarshaler *jsonpb.Marshaler) MessageRouter {
+func NewLocalMessageRouter(sessionRegistry *SessionRegistry, tracker Tracker, jsonpbMarshaler *jsonpb.Marshaler) MessageRouter {
 	return &LocalMessageRouter{
 		jsonpbMarshaler: jsonpbMarshaler,
-		registry:        registry,
+		sessionRegistry: sessionRegistry,
 		tracker:         tracker,
 	}
 }
 
-func (r *LocalMessageRouter) SendToPresences(logger *zap.Logger, presences []Presence, msg proto.Message) {
-	if len(presences) == 0 {
+func (r *LocalMessageRouter) SendToPresenceIDs(logger *zap.Logger, presenceIDs []*PresenceID, envelope *rtapi.Envelope) {
+	if len(presenceIDs) == 0 {
 		return
 	}
 
-	payload, err := r.jsonpbMarshaler.MarshalToString(msg)
+	payload, err := r.jsonpbMarshaler.MarshalToString(envelope)
 	if err != nil {
 		logger.Error("Could not marshall message to json", zap.Error(err))
 		return
 	}
 	payloadBytes := []byte(payload)
-	for _, presence := range presences {
-		session := r.registry.Get(presence.ID.SessionID)
+	for _, presenceID := range presenceIDs {
+		session := r.sessionRegistry.Get(presenceID.SessionID)
 		if session == nil {
-			logger.Warn("No session to route to", zap.Any("sid", presence.ID.SessionID))
+			if logger.Core().Enabled(zap.DebugLevel) {
+				logger.Debug("No session to route to", zap.Any("sid", presenceID.SessionID))
+			}
 			continue
 		}
-		err := session.SendBytes(payloadBytes)
-		if err != nil {
-			logger.Error("Failed to route to", zap.Any("sid", presence.ID.SessionID), zap.Error(err))
+		if err := session.SendBytes(payloadBytes); err != nil {
+			logger.Error("Failed to route to", zap.Any("sid", presenceID.SessionID), zap.Error(err))
 		}
 	}
 }
 
-func (r *LocalMessageRouter) SendToStream(logger *zap.Logger, stream PresenceStream, msg proto.Message) {
-	presences := r.tracker.ListByStream(stream)
-	r.SendToPresences(logger, presences, msg)
+func (r *LocalMessageRouter) SendToStream(logger *zap.Logger, stream PresenceStream, envelope *rtapi.Envelope) {
+	presenceIDs := r.tracker.ListPresenceIDByStream(stream)
+	r.SendToPresenceIDs(logger, presenceIDs, envelope)
 }
diff --git a/server/pipeline.go b/server/pipeline.go
index 2a3ba25fd..b6c8cefca 100644
--- a/server/pipeline.go
+++ b/server/pipeline.go
@@ -18,31 +18,38 @@ import (
 	"database/sql"
 	"fmt"
 
+	"errors"
 	"github.com/heroiclabs/nakama/rtapi"
 	"go.uber.org/zap"
 )
 
+var ErrPipelineUnrecognizedPayload = errors.New("pipeline received unrecognized payload")
+
 type pipeline struct {
-	config      Config
-	db          *sql.DB
-	registry    *SessionRegistry
-	tracker     Tracker
-	router      MessageRouter
-	runtimePool *RuntimePool
+	config          Config
+	db              *sql.DB
+	sessionRegistry *SessionRegistry
+	matchRegistry   MatchRegistry
+	tracker         Tracker
+	router          MessageRouter
+	runtimePool     *RuntimePool
+	node            string
 }
 
-func NewPipeline(config Config, db *sql.DB, registry *SessionRegistry, tracker Tracker, router MessageRouter, runtimePool *RuntimePool) *pipeline {
+func NewPipeline(config Config, db *sql.DB, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, runtimePool *RuntimePool) *pipeline {
 	return &pipeline{
-		config:      config,
-		db:          db,
-		registry:    registry,
-		tracker:     tracker,
-		router:      router,
-		runtimePool: runtimePool,
+		config:          config,
+		db:              db,
+		sessionRegistry: sessionRegistry,
+		matchRegistry:   matchRegistry,
+		tracker:         tracker,
+		router:          router,
+		runtimePool:     runtimePool,
+		node:            config.GetName(),
 	}
 }
 
-func (p *pipeline) processRequest(logger *zap.Logger, session session, envelope *rtapi.Envelope) {
+func (p *pipeline) processRequest(logger *zap.Logger, session session, envelope *rtapi.Envelope) error {
 	if logger.Core().Enabled(zap.DebugLevel) {
 		logger.Debug(fmt.Sprintf("Received %T message", envelope.Message), zap.Any("message", envelope.Message))
 	}
@@ -63,6 +70,9 @@ func (p *pipeline) processRequest(logger *zap.Logger, session session, envelope
 			Code:    int32(rtapi.Error_UNRECOGNIZED_PAYLOAD),
 			Message: "Unrecognized payload",
 		}}})
-		return
+		// If we reached this point the envelope was valid but the contents are missing or unknown.
+		// Usually caused by a version mismatch, and should cause the session making this pipeline request to close.
+		return ErrPipelineUnrecognizedPayload
 	}
+	return nil
 }
diff --git a/server/pipeline_match.go b/server/pipeline_match.go
index 8aeec4175..2cf59e4e3 100644
--- a/server/pipeline_match.go
+++ b/server/pipeline_match.go
@@ -15,9 +15,12 @@
 package server
 
 import (
+	"fmt"
+	"github.com/golang/protobuf/ptypes/wrappers"
 	"github.com/heroiclabs/nakama/rtapi"
 	"github.com/satori/go.uuid"
 	"go.uber.org/zap"
+	"strings"
 )
 
 type matchDataFilter struct {
@@ -30,10 +33,13 @@ func (p *pipeline) matchCreate(logger *zap.Logger, session session, envelope *rt
 
 	username := session.Username()
 
-	p.tracker.Track(session.ID(), PresenceStream{Mode: StreamModeMatchRelayed, Subject: matchID}, session.UserID(), PresenceMeta{
+	if success, _ := p.tracker.Track(session.ID(), PresenceStream{Mode: StreamModeMatchRelayed, Subject: matchID}, session.UserID(), PresenceMeta{
 		Username: username,
 		Format:   session.Format(),
-	})
+	}, false); !success {
+		// Presence creation was rejected due to `allowIfFirstForSession` flag, session is gone so no need to reply.
+		return
+	}
 
 	self := &rtapi.StreamPresence{
 		UserId:    session.UserID().String(),
@@ -42,7 +48,10 @@ func (p *pipeline) matchCreate(logger *zap.Logger, session session, envelope *rt
 	}
 
 	session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Match{Match: &rtapi.Match{
-		MatchId:   matchID.String(),
+		MatchId:       fmt.Sprintf("%v:", matchID.String()),
+		Authoritative: false,
+		// No label.
+		Size:      1,
 		Presences: []*rtapi.StreamPresence{self},
 		Self:      self,
 	}}})
@@ -52,12 +61,22 @@ func (p *pipeline) matchJoin(logger *zap.Logger, session session, envelope *rtap
 	m := envelope.GetMatchJoin()
 	var err error
 	var matchID uuid.UUID
+	var node string
 	var matchIDString string
 
 	switch m.Id.(type) {
 	case *rtapi.MatchJoin_MatchId:
 		matchIDString = m.GetMatchId()
-		matchID, err = uuid.FromString(matchIDString)
+		// Validate the match ID.
+		matchIDComponents := strings.SplitN(matchIDString, ":", 2)
+		if len(matchIDComponents) != 2 {
+			session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
+				Code:    int32(rtapi.Error_BAD_INPUT),
+				Message: "Invalid match ID",
+			}}})
+			return
+		}
+		matchID, err = uuid.FromString(matchIDComponents[0])
 		if err != nil {
 			session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
 				Code:    int32(rtapi.Error_BAD_INPUT),
@@ -65,8 +84,9 @@ func (p *pipeline) matchJoin(logger *zap.Logger, session session, envelope *rtap
 			}}})
 			return
 		}
+		node = matchIDComponents[1]
 	case *rtapi.MatchJoin_Token:
-		// TODO restore when matchmaking is available
+		// TODO Restore token-based join behaviour when matchmaking is available.
 		session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
 			Code:    int32(rtapi.Error_BAD_INPUT),
 			Message: "Token-based match join not available",
@@ -86,9 +106,16 @@ func (p *pipeline) matchJoin(logger *zap.Logger, session session, envelope *rtap
 		return
 	}
 
-	stream := PresenceStream{Mode: StreamModeMatchRelayed, Subject: matchID}
+	// Decide if it's an authoritative or relayed match.
+	mode := StreamModeMatchRelayed
+	if node != "" {
+		mode = StreamModeMatchAuthoritative
+	}
+
+	stream := PresenceStream{Mode: mode, Subject: matchID, Label: node}
 
-	if !p.tracker.StreamExists(stream) {
+	if mode == StreamModeMatchRelayed && !p.tracker.StreamExists(stream) {
+		// Relayed matches must 'exist' by already having some members.
 		session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
 			Code:    int32(rtapi.Error_MATCH_NOT_FOUND),
 			Message: "Match not found",
@@ -96,13 +123,50 @@ func (p *pipeline) matchJoin(logger *zap.Logger, session session, envelope *rtap
 		return
 	}
 
-	username := session.Username()
-
-	p.tracker.Track(session.ID(), stream, session.UserID(), PresenceMeta{
-		Username: username,
-		Format:   session.Format(),
-	})
+	var label *wrappers.StringValue
+	meta := p.tracker.GetLocalBySessionIDStreamUserID(session.ID(), stream, session.UserID())
+	if meta == nil {
+		username := session.Username()
+		found := true
+		allow := true
+		var l string
+		// The user is not yet part of the match, attempt to join.
+		if mode == StreamModeMatchAuthoritative {
+			// If it's an authoritative match, ask the match handler if it will allow the join.
+			found, allow, l = p.matchRegistry.Join(matchID, node, session.UserID(), session.ID(), username, p.node)
+		}
+		if !found {
+			// Match did not exist.
+			session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
+				Code:    int32(rtapi.Error_MATCH_NOT_FOUND),
+				Message: "Match join rejected",
+			}}})
+			return
+		}
+		if !allow {
+			// Match exists, but rejected the join.
+			session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
+				Code:    int32(rtapi.Error_MATCH_JOIN_REJECTED),
+				Message: "Match join rejected",
+			}}})
+			return
+		}
+		if mode == StreamModeMatchAuthoritative {
+			// If we've reached here, it was an accepted authoritative join.
+			label = &wrappers.StringValue{Value: l}
+		}
+		m := PresenceMeta{
+			Username: username,
+			Format:   session.Format(),
+		}
+		if success, _ := p.tracker.Track(session.ID(), stream, session.UserID(), m, false); !success {
+			// Presence creation was rejected due to `allowIfFirstForSession` flag, session is gone so no need to reply.
+			return
+		}
+		meta = &m
+	}
 
+	// Whether the user has just (successfully) joined the match or was already a member, return the match info anyway.
 	ps := p.tracker.ListByStream(stream)
 	presences := make([]*rtapi.StreamPresence, 0, len(ps))
 	for _, p := range ps {
@@ -115,37 +179,47 @@ func (p *pipeline) matchJoin(logger *zap.Logger, session session, envelope *rtap
 	self := &rtapi.StreamPresence{
 		UserId:    session.UserID().String(),
 		SessionId: session.ID().String(),
-		Username:  username,
+		Username:  meta.Username,
 	}
 
 	session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Match{Match: &rtapi.Match{
-		MatchId:   matchIDString,
-		Presences: presences,
-		Self:      self,
+		MatchId:       matchIDString,
+		Authoritative: mode == StreamModeMatchAuthoritative,
+		Label:         label,
+		Size:          int32(len(presences)),
+		Presences:     presences,
+		Self:          self,
 	}}})
 }
 
 func (p *pipeline) matchLeave(logger *zap.Logger, session session, envelope *rtapi.Envelope) {
-	matchIDString := envelope.GetMatchLeave().MatchId
-	matchID, err := uuid.FromString(matchIDString)
-	if err != nil {
+	// Validate the match ID.
+	matchIDComponents := strings.SplitN(envelope.GetMatchLeave().MatchId, ":", 2)
+	if len(matchIDComponents) != 2 {
 		session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
 			Code:    int32(rtapi.Error_BAD_INPUT),
 			Message: "Invalid match ID",
 		}}})
 		return
 	}
-
-	stream := PresenceStream{Mode: StreamModeMatchRelayed, Subject: matchID}
-
-	if p.tracker.GetLocalBySessionIDStreamUserID(session.ID(), stream, session.UserID()) == nil {
+	matchID, err := uuid.FromString(matchIDComponents[0])
+	if err != nil {
 		session.Send(&rtapi.Envelope{Cid: envelope.Cid, Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
-			Code:    int32(rtapi.Error_MATCH_NOT_FOUND),
-			Message: "Match not found",
+			Code:    int32(rtapi.Error_BAD_INPUT),
+			Message: "Invalid match ID",
 		}}})
 		return
 	}
 
+	// Decide if it's an authoritative or relayed match.
+	mode := StreamModeMatchRelayed
+	if matchIDComponents[1] != "" {
+		mode = StreamModeMatchAuthoritative
+	}
+
+	// Check and drop the presence if possible, will always succeed.
+	stream := PresenceStream{Mode: mode, Subject: matchID, Label: matchIDComponents[1]}
+
 	p.tracker.Untrack(session.ID(), stream, session.UserID())
 
 	session.Send(&rtapi.Envelope{Cid: envelope.Cid})
@@ -153,12 +227,29 @@ func (p *pipeline) matchLeave(logger *zap.Logger, session session, envelope *rta
 
 func (p *pipeline) matchDataSend(logger *zap.Logger, session session, envelope *rtapi.Envelope) {
 	incoming := envelope.GetMatchDataSend()
-	matchIDString := incoming.MatchId
-	matchID, err := uuid.FromString(matchIDString)
+
+	// Validate the match ID.
+	matchIDComponents := strings.SplitN(incoming.MatchId, ":", 2)
+	if len(matchIDComponents) != 2 {
+		return
+	}
+	matchID, err := uuid.FromString(matchIDComponents[0])
 	if err != nil {
 		return
 	}
 
+	// If it's an authoritative match pass the data to the match handler.
+	if matchIDComponents[1] != "" {
+		if p.tracker.GetLocalBySessionIDStreamUserID(session.ID(), PresenceStream{Mode: StreamModeMatchAuthoritative, Subject: matchID, Label: matchIDComponents[1]}, session.UserID()) == nil {
+			// User is not part of the match.
+			return
+		}
+
+		p.matchRegistry.SendData(matchID, matchIDComponents[1], session.UserID(), session.ID(), session.Username(), p.node, incoming.OpCode, incoming.Data)
+		return
+	}
+
+	// Parse any filters.
 	var filters []*matchDataFilter
 	if len(incoming.Presences) != 0 {
 		filters = make([]*matchDataFilter, len(incoming.Presences))
@@ -175,19 +266,20 @@ func (p *pipeline) matchDataSend(logger *zap.Logger, session session, envelope *
 		}
 	}
 
+	// If it was a relayed match, proceed with filter and data routing logic.
 	stream := PresenceStream{Mode: StreamModeMatchRelayed, Subject: matchID}
-	ps := p.tracker.ListByStream(stream)
-	if len(ps) == 0 {
+	presenceIDs := p.tracker.ListPresenceIDByStream(stream)
+	if len(presenceIDs) == 0 {
 		return
 	}
 
 	senderFound := false
-	for i := 0; i < len(ps); i++ {
-		p := ps[i]
-		if p.ID.SessionID == session.ID() && p.UserID == session.UserID() {
+	for i := 0; i < len(presenceIDs); i++ {
+		presenceID := presenceIDs[i]
+		if presenceID.SessionID == session.ID() {
 			// Don't echo back to sender.
-			ps[i] = ps[len(ps)-1]
-			ps = ps[:len(ps)-1]
+			presenceIDs[i] = presenceIDs[len(presenceIDs)-1]
+			presenceIDs = presenceIDs[:len(presenceIDs)-1]
 			senderFound = true
 			if filters == nil {
 				break
@@ -198,7 +290,7 @@ func (p *pipeline) matchDataSend(logger *zap.Logger, session session, envelope *
 			// Check if this presence is specified in the filters.
 			filterFound := false
 			for j := 0; j < len(filters); j++ {
-				if filter := filters[j]; p.ID.SessionID == filter.sessionID && p.UserID == filter.userID {
+				if filter := filters[j]; presenceID.SessionID == filter.sessionID {
 					// If a filter matches, drop it.
 					filters[j] = filters[len(filters)-1]
 					filters = filters[:len(filters)-1]
@@ -208,8 +300,8 @@ func (p *pipeline) matchDataSend(logger *zap.Logger, session session, envelope *
 			}
 			if !filterFound {
 				// If this presence wasn't in the filters, it's not needed.
-				ps[i] = ps[len(ps)-1]
-				ps = ps[:len(ps)-1]
+				presenceIDs[i] = presenceIDs[len(presenceIDs)-1]
+				presenceIDs = presenceIDs[:len(presenceIDs)-1]
 				i--
 			}
 		}
@@ -221,12 +313,12 @@ func (p *pipeline) matchDataSend(logger *zap.Logger, session session, envelope *
 	}
 
 	// Check if there are any recipients left.
-	if len(ps) == 0 {
+	if len(presenceIDs) == 0 {
 		return
 	}
 
 	outgoing := &rtapi.Envelope{Message: &rtapi.Envelope_MatchData{MatchData: &rtapi.MatchData{
-		MatchId: matchIDString,
+		MatchId: incoming.MatchId,
 		Presence: &rtapi.StreamPresence{
 			UserId:    session.UserID().String(),
 			SessionId: session.ID().String(),
@@ -236,5 +328,5 @@ func (p *pipeline) matchDataSend(logger *zap.Logger, session session, envelope *
 		Data:   incoming.Data,
 	}}}
 
-	p.router.SendToPresences(logger, ps, outgoing)
+	p.router.SendToPresenceIDs(logger, presenceIDs, outgoing)
 }
diff --git a/server/pipeline_rpc.go b/server/pipeline_rpc.go
index 807a5766e..1b8c5b4b9 100644
--- a/server/pipeline_rpc.go
+++ b/server/pipeline_rpc.go
@@ -54,7 +54,7 @@ func (p *pipeline) rpc(logger *zap.Logger, session session, envelope *rtapi.Enve
 		return
 	}
 
-	result, fnErr := runtime.InvokeFunctionRPC(lf, session.UserID().String(), session.Username(), session.Expiry(), session.ID().String(), rpcMessage.Payload)
+	result, fnErr, _ := runtime.InvokeFunctionRPC(lf, session.UserID().String(), session.Username(), session.Expiry(), session.ID().String(), rpcMessage.Payload)
 	p.runtimePool.Put(runtime)
 	if fnErr != nil {
 		logger.Error("Runtime RPC function caused an error", zap.String("id", rpcMessage.Id), zap.Error(fnErr))
diff --git a/server/runtime.go b/server/runtime.go
index 54f65e673..eb1afcbc3 100644
--- a/server/runtime.go
+++ b/server/runtime.go
@@ -15,29 +15,31 @@
 package server
 
 import (
-	"os"
-	"path/filepath"
-
 	"errors"
 
-	"strings"
-
 	"database/sql"
 
 	"bytes"
-	"io/ioutil"
 	"sync"
 
 	"context"
 
+	"github.com/heroiclabs/nakama/social"
 	"github.com/yuin/gopher-lua"
 	"go.uber.org/zap"
-	"github.com/heroiclabs/nakama/social"
+	"google.golang.org/grpc/codes"
 )
 
-const (
-	__nakamaReturnValue = "__nakama_return_flag__"
-)
+const LTSentinel = lua.LValueType(-1)
+
+type LSentinelType struct {
+	lua.LNilType
+}
+
+func (s *LSentinelType) String() string       { return "" }
+func (s *LSentinelType) Type() lua.LValueType { return LTSentinel }
+
+var LSentinel = lua.LValue(&LSentinelType{})
 
 type RuntimeModule struct {
 	name    string
@@ -46,92 +48,26 @@ type RuntimeModule struct {
 }
 
 type RuntimePool struct {
-	once    *sync.Once // Used to govern once-per-server-start executions.
 	regRPC  map[string]struct{}
-	stdLibs map[string]lua.LGFunction
 	modules *sync.Map
 	pool    *sync.Pool
 }
 
-func NewRuntimePool(logger *zap.Logger, multiLogger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, registry *SessionRegistry, tracker Tracker, router MessageRouter) (*RuntimePool, error) {
-	runtimeConfig := config.GetRuntime()
-	if err := os.MkdirAll(runtimeConfig.Path, os.ModePerm); err != nil {
-		return nil, err
-	}
-
-	rp := &RuntimePool{
-		once:    &sync.Once{},
-		regRPC:  make(map[string]struct{}),
-		modules: new(sync.Map),
-		stdLibs: map[string]lua.LGFunction{
-			lua.LoadLibName:   lua.OpenPackage,
-			lua.BaseLibName:   lua.OpenBase,
-			lua.TabLibName:    lua.OpenTable,
-			lua.OsLibName:     OpenOs,
-			lua.StringLibName: lua.OpenString,
-			lua.MathLibName:   lua.OpenMath,
-		},
-	}
-
-	// Override before Package library is invoked.
-	lua.LuaLDir = runtimeConfig.Path
-	lua.LuaPathDefault = lua.LuaLDir + "/?.lua;" + lua.LuaLDir + "/?/init.lua"
-	os.Setenv(lua.LuaPath, lua.LuaPathDefault)
-
-	logger.Info("Initialising modules", zap.String("path", lua.LuaLDir))
-	modulePaths := make([]string, 0)
-	if err := filepath.Walk(lua.LuaLDir, func(path string, f os.FileInfo, err error) error {
-		if err != nil {
-			logger.Error("Could not read module", zap.Error(err))
-			return err
-		} else if !f.IsDir() {
-			if strings.ToLower(filepath.Ext(path)) == ".lua" {
-				var content []byte
-				if content, err = ioutil.ReadFile(path); err != nil {
-					logger.Error("Could not read module", zap.String("path", path), zap.Error(err))
-					return err
+func NewRuntimePool(logger, multiLogger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, stdLibs map[string]lua.LGFunction, modules *sync.Map, regRPC map[string]struct{}, once *sync.Once) *RuntimePool {
+	return &RuntimePool{
+		regRPC:  regRPC,
+		modules: modules,
+		pool: &sync.Pool{
+			New: func() interface{} {
+				r, err := newVM(logger, db, config, socialClient, sessionRegistry, matchRegistry, tracker, router, stdLibs, modules, once, nil)
+				if err != nil {
+					multiLogger.Fatal("Failed initializing runtime.", zap.Error(err))
 				}
-				relPath, _ := filepath.Rel(lua.LuaLDir, path)
-				name := strings.TrimSuffix(relPath, filepath.Ext(relPath))
-				// Make paths Lua friendly.
-				name = strings.Replace(name, "/", ".", -1)
-				rp.modules.Store(path, &RuntimeModule{
-					name:    name,
-					path:    path,
-					content: content,
-				})
-				modulePaths = append(modulePaths, relPath)
-			}
-		}
-		return nil
-	}); err != nil {
-		logger.Error("Failed to list modules", zap.Error(err))
-		return nil, err
-	}
-
-	multiLogger.Info("Evaluating modules", zap.Int("count", len(modulePaths)), zap.Strings("modules", modulePaths))
-	r, err := rp.newVM(logger, db, config, socialClient, registry, tracker, router, func(id string) {
-		rp.regRPC[id] = struct{}{}
-		logger.Info("Registered RPC function invocation", zap.String("id", id))
-	})
-	if err != nil {
-		return nil, err
-	}
-	multiLogger.Info("Modules loaded")
-	r.Stop()
-
-	rp.pool = &sync.Pool{
-		New: func() interface{} {
-			r, err := rp.newVM(logger, db, config, socialClient, registry, tracker, router, nil)
-			if err != nil {
-				multiLogger.Fatal("Failed initializing runtime.", zap.Error(err))
-			}
-			// TODO find a way to run r.Stop() when the pool discards this runtime.
-			return r
+				// TODO find a way to run r.Stop() when the pool discards this runtime.
+				return r
+			},
 		},
 	}
-
-	return rp, nil
 }
 
 func (rp *RuntimePool) HasRPC(id string) bool {
@@ -147,7 +83,7 @@ func (rp *RuntimePool) Put(r *Runtime) {
 	rp.pool.Put(r)
 }
 
-func (rp *RuntimePool) newVM(logger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, registry *SessionRegistry, tracker Tracker, router MessageRouter, announceRPC func(string)) (*Runtime, error) {
+func newVM(logger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, stdLibs map[string]lua.LGFunction, modules *sync.Map, once *sync.Once, announceRPC func(string)) (*Runtime, error) {
 	// Initialize a one-off runtime to ensure startup code runs and modules are valid.
 	vm := lua.NewState(lua.Options{
 		CallStackSize:       1024,
@@ -155,12 +91,12 @@ func (rp *RuntimePool) newVM(logger *zap.Logger, db *sql.DB, config Config, soci
 		SkipOpenLibs:        true,
 		IncludeGoStackTrace: true,
 	})
-	for name, lib := range rp.stdLibs {
+	for name, lib := range stdLibs {
 		vm.Push(vm.NewFunction(lib))
 		vm.Push(lua.LString(name))
 		vm.Call(1, 0)
 	}
-	nakamaModule := NewNakamaModule(logger, db, config, socialClient, vm, registry, tracker, router, rp.once, announceRPC)
+	nakamaModule := NewNakamaModule(logger, db, config, socialClient, vm, sessionRegistry, matchRegistry, tracker, router, once, announceRPC)
 	vm.PreloadModule("nakama", nakamaModule.Loader)
 	r := &Runtime{
 		logger: logger,
@@ -168,13 +104,13 @@ func (rp *RuntimePool) newVM(logger *zap.Logger, db *sql.DB, config Config, soci
 		luaEnv: ConvertMap(vm, config.GetRuntime().Environment),
 	}
 
-	modules := make([]*RuntimeModule, 0)
-	rp.modules.Range(func(key interface{}, value interface{}) bool {
-		modules = append(modules, value.(*RuntimeModule))
+	mods := make([]*RuntimeModule, 0)
+	modules.Range(func(key interface{}, value interface{}) bool {
+		mods = append(mods, value.(*RuntimeModule))
 		return true
 	})
 
-	return r, r.loadModules(modules)
+	return r, r.loadModules(mods)
 }
 
 type Runtime struct {
@@ -261,7 +197,7 @@ func (r *Runtime) GetRuntimeCallback(e ExecutionMode, key string) *lua.LFunction
 	return nil
 }
 
-func (r *Runtime) InvokeFunctionRPC(fn *lua.LFunction, uid string, username string, sessionExpiry int64, sid string, payload string) (string, error) {
+func (r *Runtime) InvokeFunctionRPC(fn *lua.LFunction, uid string, username string, sessionExpiry int64, sid string, payload string) (string, error, codes.Code) {
 	l, _ := r.NewStateThread()
 	defer l.Close()
 
@@ -271,22 +207,22 @@ func (r *Runtime) InvokeFunctionRPC(fn *lua.LFunction, uid string, username stri
 		lv = lua.LString(payload)
 	}
 
-	retValue, err := r.invokeFunction(l, fn, ctx, lv)
+	retValue, err, code := r.invokeFunction(l, fn, ctx, lv)
 	if err != nil {
-		return "", err
+		return "", err, code
 	}
 
 	if retValue == nil || retValue == lua.LNil {
-		return "", nil
+		return "", nil, 0
 	} else if retValue.Type() == lua.LTString {
-		return retValue.String(), nil
+		return retValue.String(), nil, 0
 	}
 
-	return "", errors.New("runtime function returned invalid data - only allowed one return value of type String/Byte")
+	return "", errors.New("runtime function returned invalid data - only allowed one return value of type String/Byte"), codes.Internal
 }
 
-func (r *Runtime) invokeFunction(l *lua.LState, fn *lua.LFunction, ctx *lua.LTable, payload lua.LValue) (lua.LValue, error) {
-	l.Push(lua.LString(__nakamaReturnValue))
+func (r *Runtime) invokeFunction(l *lua.LState, fn *lua.LFunction, ctx *lua.LTable, payload lua.LValue) (lua.LValue, error, codes.Code) {
+	l.Push(LSentinel)
 	l.Push(fn)
 
 	nargs := 1
@@ -299,15 +235,53 @@ func (r *Runtime) invokeFunction(l *lua.LState, fn *lua.LFunction, ctx *lua.LTab
 
 	err := l.PCall(nargs, lua.MultRet, nil)
 	if err != nil {
-		return nil, err
+		// Unwind the stack up to and including our sentinel value, effectively discarding any other returned parameters.
+		for {
+			v := l.Get(-1)
+			l.Pop(1)
+			if v.Type() == LTSentinel {
+				break
+			}
+		}
+
+		if apiError, ok := err.(*lua.ApiError); ok && apiError.Object.Type() == lua.LTTable {
+			t := apiError.Object.(*lua.LTable)
+			switch t.Len() {
+			case 0:
+				return nil, err, codes.Internal
+			case 1:
+				apiError.Object = t.RawGetInt(1)
+				return nil, err, codes.Internal
+			default:
+				// Ignore everything beyond the first 2 params, if there are more.
+				apiError.Object = t.RawGetInt(1)
+				code := codes.Internal
+				if c := t.RawGetInt(2); c.Type() == lua.LTNumber {
+					code = codes.Code(c.(lua.LNumber))
+				}
+				return nil, err, code
+			}
+		}
+
+		return nil, err, codes.Internal
 	}
 
 	retValue := l.Get(-1)
-	if retValue.Type() == lua.LTString && lua.LVAsString(retValue) == __nakamaReturnValue {
-		return nil, nil
+	l.Pop(1)
+	if retValue.Type() == LTSentinel {
+		return nil, nil, 0
+	}
+
+	// Unwind the stack up to and including our sentinel value, effectively discarding any other returned parameters.
+	for {
+		v := l.Get(-1)
+		l.Pop(1)
+		if v.Type() == LTSentinel {
+			break
+		}
 	}
 
-	return retValue, nil
+	return retValue, nil, 0
 }
 
 func (r *Runtime) Stop() {
diff --git a/server/runtime_loadlib.go b/server/runtime_loadlib.go
new file mode 100644
index 000000000..d45c89995
--- /dev/null
+++ b/server/runtime_loadlib.go
@@ -0,0 +1,131 @@
+// The MIT License (MIT)
+//
+// Copyright (c) 2015 Yusuke Inuzuka
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package server
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/yuin/gopher-lua"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+const emptyLString lua.LString = lua.LString("")
+
+func loGetPath(env string, defpath string) string {
+	path := os.Getenv(env)
+	if len(path) == 0 {
+		path = defpath
+	}
+	path = strings.Replace(path, ";;", ";"+defpath+";", -1)
+	if os.PathSeparator != '/' {
+		dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
+		if err != nil {
+			panic(err)
+		}
+		path = strings.Replace(path, "!", dir, -1)
+	}
+	return path
+}
+
+func OpenPackage(modules *sync.Map) func(L *lua.LState) int {
+	return func(L *lua.LState) int {
+		loLoaderCache := func(L *lua.LState) int {
+			name := L.CheckString(1)
+			m, ok := modules.Load(name)
+			if !ok {
+				L.Push(lua.LString(fmt.Sprintf("no cached module '%s'", name)))
+				return 1
+			}
+			module, ok := m.(*RuntimeModule)
+			if !ok {
+				L.Push(lua.LString(fmt.Sprintf("invalid cached module '%s'", name)))
+				return 1
+			}
+			fn, err := L.Load(bytes.NewReader(module.content), module.path)
+			if err != nil {
+				L.RaiseError(err.Error())
+			}
+			L.Push(fn)
+			return 1
+		}
+
+		packagemod := L.RegisterModule(lua.LoadLibName, loFuncs)
+
+		L.SetField(packagemod, "preload", L.NewTable())
+
+		loaders := L.CreateTable(2, 0)
+		L.RawSetInt(loaders, 1, L.NewFunction(loLoaderPreload))
+		L.RawSetInt(loaders, 2, L.NewFunction(loLoaderCache))
+		L.SetField(packagemod, "loaders", loaders)
+		L.SetField(L.Get(lua.RegistryIndex), "_LOADERS", loaders)
+
+		loaded := L.NewTable()
+		L.SetField(packagemod, "loaded", loaded)
+		L.SetField(L.Get(lua.RegistryIndex), "_LOADED", loaded)
+
+		L.SetField(packagemod, "path", lua.LString(loGetPath(lua.LuaPath, lua.LuaPathDefault)))
+		L.SetField(packagemod, "cpath", emptyLString)
+
+		L.Push(packagemod)
+		return 1
+	}
+}
+
+var loFuncs = map[string]lua.LGFunction{
+	"loadlib": loLoadLib,
+	"seeall":  loSeeAll,
+}
+
+func loLoaderPreload(L *lua.LState) int {
+	name := L.CheckString(1)
+	preload := L.GetField(L.GetField(L.Get(lua.EnvironIndex), "package"), "preload")
+	if _, ok := preload.(*lua.LTable); !ok {
+		L.RaiseError("package.preload must be a table")
+	}
+	lv := L.GetField(preload, name)
+	if lv == lua.LNil {
+		L.Push(lua.LString(fmt.Sprintf("no field package.preload['%s']", name)))
+		return 1
+	}
+	L.Push(lv)
+	return 1
+}
+
+func loLoadLib(L *lua.LState) int {
+	L.RaiseError("loadlib is not supported")
+	return 0
+}
+
+func loSeeAll(L *lua.LState) int {
+	mod := L.CheckTable(1)
+	mt := L.GetMetatable(mod)
+	if mt == lua.LNil {
+		mt = L.CreateTable(0, 1)
+		L.SetMetatable(mod, mt)
+	}
+	L.SetField(mt, "__index", L.Get(lua.GlobalsIndex))
+	return 0
+}
diff --git a/server/runtime_lua_context.go b/server/runtime_lua_context.go
index 22b1e2bab..3a2ebce93 100644
--- a/server/runtime_lua_context.go
+++ b/server/runtime_lua_context.go
@@ -24,12 +24,15 @@ type ExecutionMode int
 
 const (
 	RPC ExecutionMode = iota
+	Match
 )
 
 func (e ExecutionMode) String() string {
 	switch e {
 	case RPC:
 		return "rpc"
+	case Match:
+		return "match"
 	}
 
 	return ""
@@ -42,10 +45,22 @@ const (
 	__CTX_USERNAME         = "Username"
 	__CTX_USER_SESSION_EXP = "UserSessionExp"
 	__CTX_SESSION_ID       = "SessionId"
+	__CTX_MATCH_ID         = "MatchId"
+	__CTX_MATCH_NODE       = "MatchNode"
+	__CTX_MATCH_LABEL      = "MatchLabel"
+	__CTX_MATCH_TICK_RATE  = "MatchTickRate"
 )
 
 func NewLuaContext(l *lua.LState, env *lua.LTable, mode ExecutionMode, uid string, username string, sessionExpiry int64, sid string) *lua.LTable {
-	lt := l.NewTable()
+	size := 2
+	if uid != "" {
+		size += 3
+		if sid != "" {
+			size++
+		}
+	}
+
+	lt := l.CreateTable(size, size)
 	lt.RawSetString(__CTX_ENV, env)
 	lt.RawSetString(__CTX_MODE, lua.LString(mode.String()))
 
@@ -53,16 +68,17 @@ func NewLuaContext(l *lua.LState, env *lua.LTable, mode ExecutionMode, uid strin
 		lt.RawSetString(__CTX_USER_ID, lua.LString(uid))
 		lt.RawSetString(__CTX_USERNAME, lua.LString(username))
 		lt.RawSetString(__CTX_USER_SESSION_EXP, lua.LNumber(sessionExpiry))
-	}
-	if sid != "" {
-		lt.RawSetString(__CTX_SESSION_ID, lua.LString(sid))
+		if sid != "" {
+			lt.RawSetString(__CTX_SESSION_ID, lua.LString(sid))
+		}
 	}
 
 	return lt
 }
 
 func ConvertMap(l *lua.LState, data map[string]interface{}) *lua.LTable {
-	lt := l.NewTable()
+	size := len(data)
+	lt := l.CreateTable(size, size)
 
 	for k, v := range data {
 		lt.RawSetString(k, convertValue(l, v))
@@ -108,7 +124,8 @@ func convertValue(l *lua.LState, val interface{}) lua.LValue {
 	case map[string]interface{}:
 		return ConvertMap(l, v)
 	case []interface{}:
-		lt := l.NewTable()
+		size := len(val.([]interface{}))
+		lt := l.CreateTable(size, size)
 		for k, v := range v {
 			lt.RawSetInt(k+1, convertValue(l, v))
 		}
diff --git a/server/runtime_module_cache.go b/server/runtime_module_cache.go
new file mode 100644
index 000000000..75c3b43f7
--- /dev/null
+++ b/server/runtime_module_cache.go
@@ -0,0 +1,101 @@
+// Copyright 2018 The Nakama Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"database/sql"
+	"github.com/heroiclabs/nakama/social"
+	"github.com/yuin/gopher-lua"
+	"go.uber.org/zap"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+func LoadRuntimeModules(logger, multiLogger *zap.Logger, config Config) (map[string]lua.LGFunction, *sync.Map, error) {
+	runtimeConfig := config.GetRuntime()
+	if err := os.MkdirAll(runtimeConfig.Path, os.ModePerm); err != nil {
+		return nil, nil, err
+	}
+
+	modules := new(sync.Map)
+
+	// Override before Package library is invoked.
+	lua.LuaLDir = runtimeConfig.Path
+	lua.LuaPathDefault = lua.LuaLDir + "/?.lua;" + lua.LuaLDir + "/?/init.lua"
+	os.Setenv(lua.LuaPath, lua.LuaPathDefault)
+
+	logger.Info("Initialising modules", zap.String("path", lua.LuaLDir))
+	modulePaths := make([]string, 0)
+	if err := filepath.Walk(lua.LuaLDir, func(path string, f os.FileInfo, err error) error {
+		if err != nil {
+			logger.Error("Could not read module", zap.Error(err))
+			return err
+		} else if !f.IsDir() {
+			if strings.ToLower(filepath.Ext(path)) == ".lua" {
+				var content []byte
+				if content, err = ioutil.ReadFile(path); err != nil {
+					logger.Error("Could not read module", zap.String("path", path), zap.Error(err))
+					return err
+				}
+				relPath, _ := filepath.Rel(lua.LuaLDir, path)
+				name := strings.TrimSuffix(relPath, filepath.Ext(relPath))
+				// Make paths Lua friendly.
+				name = strings.Replace(name, "/", ".", -1)
+				modules.Store(name, &RuntimeModule{
+					name:    name,
+					path:    path,
+					content: content,
+				})
+				modulePaths = append(modulePaths, relPath)
+			}
+		}
+		return nil
+	}); err != nil {
+		logger.Error("Failed to list modules", zap.Error(err))
+		return nil, nil, err
+	}
+
+	stdLibs := map[string]lua.LGFunction{
+		lua.LoadLibName:   OpenPackage(modules),
+		lua.BaseLibName:   lua.OpenBase,
+		lua.TabLibName:    lua.OpenTable,
+		lua.OsLibName:     OpenOs,
+		lua.StringLibName: lua.OpenString,
+		lua.MathLibName:   lua.OpenMath,
+	}
+
+	multiLogger.Info("Found modules", zap.Int("count", len(modulePaths)), zap.Strings("modules", modulePaths))
+
+	return stdLibs, modules, nil
+}
+
+func ValidateRuntimeModules(logger, multiLogger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, stdLibs map[string]lua.LGFunction, modules *sync.Map, once *sync.Once) (map[string]struct{}, error) {
+	regRPC := make(map[string]struct{})
+	multiLogger.Info("Evaluating modules")
+	r, err := newVM(logger, db, config, socialClient, sessionRegistry, matchRegistry, tracker, router, stdLibs, modules, once, func(id string) {
+		regRPC[id] = struct{}{}
+		logger.Info("Registered RPC function invocation", zap.String("id", id))
+	})
+	if err != nil {
+		return nil, err
+	}
+	multiLogger.Info("Modules loaded")
+	r.Stop()
+
+	return regRPC, nil
+}
diff --git a/server/runtime_nakama_module.go b/server/runtime_nakama_module.go
index d7e5d4259..ba3be4741 100644
--- a/server/runtime_nakama_module.go
+++ b/server/runtime_nakama_module.go
@@ -40,6 +40,7 @@ import (
 	"crypto/hmac"
 	"crypto/sha256"
 	"github.com/golang/protobuf/ptypes/timestamp"
+	"github.com/golang/protobuf/ptypes/wrappers"
 	"github.com/gorhill/cronexpr"
 	"github.com/heroiclabs/nakama/api"
 	"github.com/heroiclabs/nakama/rtapi"
@@ -57,32 +58,34 @@ type Callbacks struct {
 }
 
 type NakamaModule struct {
-	logger       *zap.Logger
-	db           *sql.DB
-	config       Config
-	socialClient *social.Client
-	registry     *SessionRegistry
-	tracker      Tracker
-	router       MessageRouter
-	once         *sync.Once
-	announceRPC  func(string)
-	client       *http.Client
+	logger          *zap.Logger
+	db              *sql.DB
+	config          Config
+	socialClient    *social.Client
+	sessionRegistry *SessionRegistry
+	matchRegistry   MatchRegistry
+	tracker         Tracker
+	router          MessageRouter
+	once            *sync.Once
+	announceRPC     func(string)
+	client          *http.Client
 }
 
-func NewNakamaModule(logger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, l *lua.LState, registry *SessionRegistry, tracker Tracker, router MessageRouter, once *sync.Once, announceRPC func(string)) *NakamaModule {
+func NewNakamaModule(logger *zap.Logger, db *sql.DB, config Config, socialClient *social.Client, l *lua.LState, sessionRegistry *SessionRegistry, matchRegistry MatchRegistry, tracker Tracker, router MessageRouter, once *sync.Once, announceRPC func(string)) *NakamaModule {
 	l.SetContext(context.WithValue(context.Background(), CALLBACKS, &Callbacks{
 		RPC: make(map[string]*lua.LFunction),
 	}))
 	return &NakamaModule{
-		logger:       logger,
-		db:           db,
-		config:       config,
-		socialClient: socialClient,
-		registry:     registry,
-		tracker:      tracker,
-		router:       router,
-		once:         once,
-		announceRPC:  announceRPC,
+		logger:          logger,
+		db:              db,
+		config:          config,
+		socialClient:    socialClient,
+		sessionRegistry: sessionRegistry,
+		matchRegistry:   matchRegistry,
+		tracker:         tracker,
+		router:          router,
+		once:            once,
+		announceRPC:     announceRPC,
 		client: &http.Client{
 			Timeout: 5 * time.Second,
 		},
@@ -90,7 +93,7 @@ func NewNakamaModule(logger *zap.Logger, db *sql.DB, config Config, socialClient
 }
 
 func (n *NakamaModule) Loader(l *lua.LState) int {
-	mod := l.SetFuncs(l.NewTable(), map[string]lua.LGFunction{
+	functions := map[string]lua.LGFunction{
 		"sql_exec":                    n.sqlExec,
 		"sql_query":                   n.sqlQuery,
 		"uuid_v4":                     n.uuidV4,
@@ -128,12 +131,15 @@ func (n *NakamaModule) Loader(l *lua.LState) int {
 		"stream_count":                n.streamCount,
 		"stream_close":                n.streamClose,
 		"stream_send":                 n.streamSend,
+		"match_create":                n.matchCreate,
+		"match_list":                  n.matchList,
 		"register_rpc":                n.registerRPC,
 		"notification_send":           n.notificationSend,
 		"notifications_send":          n.notificationsSend,
 		"wallet_write":                n.walletWrite,
 		// "run_once": n.runOnce,
-	})
+	}
+	mod := l.SetFuncs(l.CreateTable(len(functions), len(functions)), functions)
 
 	l.Push(mod)
 	return 1
@@ -219,9 +225,9 @@ func (n *NakamaModule) sqlQuery(l *lua.LState) int {
 		return 0
 	}
 
-	rt := l.NewTable()
+	rt := l.CreateTable(len(resultRows), len(resultRows))
 	for i, r := range resultRows {
-		rowTable := l.NewTable()
+		rowTable := l.CreateTable(resultColumnCount, resultColumnCount)
 		for j, col := range resultColumns {
 			rowTable.RawSetString(col, convertValue(l, r[j]))
 		}
@@ -372,7 +378,13 @@ func (n *NakamaModule) base64Encode(l *lua.LState) int {
 		return 0
 	}
 
-	output := base64.StdEncoding.EncodeToString([]byte(input))
+	padding := l.OptBool(2, true)
+
+	e := base64.StdEncoding
+	if !padding {
+		e = base64.RawStdEncoding
+	}
+	output := e.EncodeToString([]byte(input))
 	l.Push(lua.LString(output))
 	return 1
 }
@@ -384,7 +396,16 @@ func (n *NakamaModule) base64Decode(l *lua.LState) int {
 		return 0
 	}
 
-	output, err := base64.RawStdEncoding.DecodeString(input)
+	padding := l.OptBool(2, false)
+
+	if !padding {
+		// Pad string up to length multiple of 4 if needed to effectively make padding optional.
+		if maybePad := len(input) % 4; maybePad != 0 {
+			input += strings.Repeat("=", 4-maybePad)
+		}
+	}
+
+	output, err := base64.StdEncoding.DecodeString(input)
 	if err != nil {
 		l.RaiseError("not a valid base64 string: %v", err.Error())
 		return 0
@@ -401,7 +422,13 @@ func (n *NakamaModule) base64URLEncode(l *lua.LState) int {
 		return 0
 	}
 
-	output := base64.URLEncoding.EncodeToString([]byte(input))
+	padding := l.OptBool(2, true)
+
+	e := base64.URLEncoding
+	if !padding {
+		e = base64.RawURLEncoding
+	}
+	output := e.EncodeToString([]byte(input))
 	l.Push(lua.LString(output))
 	return 1
 }
@@ -413,7 +440,16 @@ func (n *NakamaModule) base64URLDecode(l *lua.LState) int {
 		return 0
 	}
 
-	output, err := base64.RawURLEncoding.DecodeString(input)
+	padding := l.OptBool(2, false)
+
+	if !padding {
+		// Pad string up to length multiple of 4 if needed to effectively make padding optional.
+		if maybePad := len(input) % 4; maybePad != 0 {
+			input += strings.Repeat("=", 4-maybePad)
+		}
+	}
+
+	output, err := base64.URLEncoding.DecodeString(input)
 	if err != nil {
 		l.RaiseError("not a valid base64 url string: %v", err.Error())
 		return 0
@@ -920,7 +956,7 @@ func (n *NakamaModule) authenticateTokenGenerate(l *lua.LState) int {
 	exp := l.OptInt64(3, 0)
 	if exp == 0 {
 		// If expiry is 0 or not set, use standard configured expiry.
-		exp = time.Now().UTC().Add(time.Duration(n.config.GetSession().TokenExpiryMs) * time.Millisecond).Unix()
+		exp = time.Now().UTC().Add(time.Duration(n.config.GetSession().TokenExpirySec) * time.Second).Unix()
 	}
 
 	token := generateTokenWithExpiry(n.config, userIDString, username, exp)
@@ -1066,7 +1102,7 @@ func (n *NakamaModule) streamUserGet(l *lua.LState) int {
 	if meta == nil {
 		l.Push(lua.LNil)
 	} else {
-		metaTable := l.NewTable()
+		metaTable := l.CreateTable(4, 4)
 		metaTable.RawSetString("Hidden", lua.LBool(meta.Hidden))
 		metaTable.RawSetString("Persistence", lua.LBool(meta.Persistence))
 		metaTable.RawSetString("Username", lua.LString(meta.Username))
@@ -1158,20 +1194,24 @@ func (n *NakamaModule) streamUserJoin(l *lua.LState) int {
 	persistence := l.OptBool(5, true)
 
 	// Look up the session.
-	session := n.registry.Get(sessionID)
+	session := n.sessionRegistry.Get(sessionID)
 	if session == nil {
 		l.ArgError(2, "session id does not exist")
 		return 0
 	}
 
-	alreadyTracked := n.tracker.Track(sessionID, stream, userID, PresenceMeta{
+	success, newlyTracked := n.tracker.Track(sessionID, stream, userID, PresenceMeta{
 		Format:      session.Format(),
 		Hidden:      hidden,
 		Persistence: persistence,
 		Username:    session.Username(),
-	})
+	}, false)
+	if !success {
+		l.RaiseError("tracker rejected new presence, session is closing")
+		return 0
+	}
 
-	l.Push(lua.LBool(alreadyTracked))
+	l.Push(lua.LBool(newlyTracked))
 	return 1
 }
 
@@ -1446,6 +1486,92 @@ func (n *NakamaModule) streamSend(l *lua.LState) int {
 	return 0
 }
 
+func (n *NakamaModule) matchCreate(l *lua.LState) int {
+	// Parse the name of the Lua module that should handle the match.
+	name := l.CheckString(1)
+	if name == "" {
+		l.ArgError(1, "expects module name")
+		return 0
+	}
+
+	params := convertLuaValue(l.Get(2))
+
+	// Start the match.
+	mh, err := n.matchRegistry.NewMatch(name, params)
+	if err != nil {
+		l.RaiseError("error creating match: %v", err.Error())
+		return 0
+	}
+
+	// Return the match ID in a form that can be directly sent to clients.
+	l.Push(lua.LString(mh.IDStr))
+	return 1
+}
+
+func (n *NakamaModule) matchList(l *lua.LState) int {
+	// Parse limit.
+	limit := l.OptInt(1, 1)
+
+	// Parse authoritative flag.
+	var authoritative *wrappers.BoolValue
+	if v := l.Get(2); v.Type() != lua.LTNil {
+		if v.Type() != lua.LTBool {
+			l.ArgError(2, "expects authoritative true/false or nil")
+			return 0
+		}
+		authoritative = &wrappers.BoolValue{Value: lua.LVAsBool(v)}
+	}
+
+	// Parse label filter.
+	var label *wrappers.StringValue
+	if v := l.Get(3); v.Type() != lua.LTNil {
+		if v.Type() != lua.LTString {
+			l.ArgError(3, "expects label string or nil")
+			return 0
+		}
+		label = &wrappers.StringValue{Value: lua.LVAsString(v)}
+	}
+
+	// Parse minimum size filter.
+	var minSize *wrappers.Int32Value
+	if v := l.Get(4); v.Type() != lua.LTNil {
+		if v.Type() != lua.LTNumber {
+			l.ArgError(4, "expects minimum size number or nil")
+			return 0
+		}
+		minSize = &wrappers.Int32Value{Value: int32(lua.LVAsNumber(v))}
+	}
+
+	// Parse maximum size filter.
+	var maxSize *wrappers.Int32Value
+	if v := l.Get(5); v.Type() != lua.LTNil {
+		if v.Type() != lua.LTNumber {
+			l.ArgError(5, "expects maximum size number or nil")
+			return 0
+		}
+		maxSize = &wrappers.Int32Value{Value: int32(lua.LVAsNumber(v))}
+	}
+
+	results := n.matchRegistry.ListMatches(limit, authoritative, label, minSize, maxSize)
+
+	s := len(results)
+	matches := l.CreateTable(s, s)
+	for i, result := range results {
+		match := l.CreateTable(4, 4)
+		match.RawSetString("MatchId", lua.LString(result.MatchId))
+		match.RawSetString("Authoritative", lua.LBool(result.Authoritative))
+		if result.Label == nil {
+			match.RawSetString("Label", lua.LNil)
+		} else {
+			match.RawSetString("Label", lua.LString(result.Label.Value))
+		}
+		match.RawSetString("Size", lua.LNumber(result.Size))
+		matches.RawSetInt(i+1, match)
+	}
+	l.Push(matches)
+	return 1
+}
+
 func (n *NakamaModule) registerRPC(l *lua.LState) int {
 	fn := l.CheckFunction(1)
 	id := l.CheckString(2)
diff --git a/server/runtime_oslib.go b/server/runtime_oslib.go
index 82d797273..f0e34929f 100644
--- a/server/runtime_oslib.go
+++ b/server/runtime_oslib.go
@@ -87,7 +87,7 @@ func osDate(L *lua.LState) int {
 			t = time.Unix(L.CheckInt64(2), 0)
 		}
 		if strings.HasPrefix(cfmt, "*t") {
-			ret := L.NewTable()
+			ret := L.CreateTable(9, 9)
 			ret.RawSetString("year", lua.LNumber(t.Year()))
 			ret.RawSetString("month", lua.LNumber(t.Month()))
 			ret.RawSetString("day", lua.LNumber(t.Day()))
diff --git a/server/session_registry.go b/server/session_registry.go
index ec8ca7d3c..f67eb1e94 100644
--- a/server/session_registry.go
+++ b/server/session_registry.go
@@ -25,8 +25,8 @@ import (
 type SessionFormat uint8
 
 const (
-	SessionFormatProtobuf SessionFormat = iota
-	SessionFormatJson
+	SessionFormatJson SessionFormat = iota
+	SessionFormatProtobuf
 )
 
 type session interface {
@@ -38,7 +38,7 @@ type session interface {
 	SetUsername(string)
 
 	Expiry() int64
-	Consume(func(logger *zap.Logger, session session, envelope *rtapi.Envelope))
+	Consume(func(logger *zap.Logger, session session, envelope *rtapi.Envelope) error)
 
 	Format() SessionFormat
 	Send(envelope *rtapi.Envelope) error
diff --git a/server/session_ws.go b/server/session_ws.go
index 81c49037f..1f3598c66 100644
--- a/server/session_ws.go
+++ b/server/session_ws.go
@@ -27,9 +27,10 @@ import (
 	"github.com/satori/go.uuid"
 	"go.uber.org/atomic"
 	"go.uber.org/zap"
+	"net"
 )
 
-var ErrQueueFull = errors.New("outgoing queue full")
+var ErrSessionQueueFull = errors.New("session outgoing queue full")
 
 type sessionWS struct {
 	sync.Mutex
@@ -43,8 +44,8 @@ type sessionWS struct {
 	jsonpbMarshaler   *jsonpb.Marshaler
 	jsonpbUnmarshaler *jsonpb.Unmarshaler
 
-	registry *SessionRegistry
-	tracker  Tracker
+	sessionRegistry *SessionRegistry
+	tracker         Tracker
 
 	stopped        bool
 	conn           *websocket.Conn
@@ -53,7 +54,7 @@ type sessionWS struct {
 	outgoingStopCh chan struct{}
 }
 
-func NewSessionWS(logger *zap.Logger, config Config, userID uuid.UUID, username string, expiry int64, jsonpbMarshaler *jsonpb.Marshaler, jsonpbUnmarshaler *jsonpb.Unmarshaler, conn *websocket.Conn, registry *SessionRegistry, tracker Tracker) session {
+func NewSessionWS(logger *zap.Logger, config Config, userID uuid.UUID, username string, expiry int64, jsonpbMarshaler *jsonpb.Marshaler, jsonpbUnmarshaler *jsonpb.Unmarshaler, conn *websocket.Conn, sessionRegistry *SessionRegistry, tracker Tracker) session {
 	sessionID := uuid.NewV4()
 	sessionLogger := logger.With(zap.String("uid", userID.String()), zap.String("sid", sessionID.String()))
 
@@ -70,8 +71,8 @@ func NewSessionWS(logger *zap.Logger, config Config, userID uuid.UUID, username
 		jsonpbMarshaler:   jsonpbMarshaler,
 		jsonpbUnmarshaler: jsonpbUnmarshaler,
 
-		registry: registry,
-		tracker:  tracker,
+		sessionRegistry: sessionRegistry,
+		tracker:         tracker,
 
 		stopped:        false,
 		conn:           conn,
@@ -105,7 +106,7 @@ func (s *sessionWS) Expiry() int64 {
 	return s.expiry
 }
 
-func (s *sessionWS) Consume(processRequest func(logger *zap.Logger, session session, envelope *rtapi.Envelope)) {
+func (s *sessionWS) Consume(processRequest func(logger *zap.Logger, session session, envelope *rtapi.Envelope) error) {
 	defer s.cleanupClosedConnection()
 	s.conn.SetReadLimit(s.config.GetSocket().MaxMessageSizeBytes)
 	s.conn.SetReadDeadline(time.Now().Add(time.Duration(s.config.GetSocket().PongWaitMs) * time.Millisecond))
@@ -126,8 +127,12 @@ func (s *sessionWS) Consume(processRequest func(logger *zap.Logger, session sess
 	for {
 		_, data, err := s.conn.ReadMessage()
 		if err != nil {
+			// Ignore "normal" WebSocket errors.
 			if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
-				s.logger.Warn("Error reading message from client", zap.Error(err))
+				// Ignore underlying connection being shut down while read is waiting for data.
+				if e, ok := err.(*net.OpError); !ok || e.Err.Error() != "use of closed network connection" {
+					s.logger.Warn("Error reading message from client", zap.Error(err))
+				}
 			}
 			break
 		}
@@ -136,15 +141,14 @@ func (s *sessionWS) Consume(processRequest func(logger *zap.Logger, session sess
 		if err = s.jsonpbUnmarshaler.Unmarshal(bytes.NewReader(data), request); err != nil {
 			// If the payload is malformed the client is incompatible or misbehaving, either way disconnect it now.
 			s.logger.Warn("Received malformed payload", zap.String("data", string(data)))
-			s.Send(&rtapi.Envelope{Message: &rtapi.Envelope_Error{Error: &rtapi.Error{
-				Code:    int32(rtapi.Error_UNRECOGNIZED_PAYLOAD),
-				Message: "Unrecognized payload",
-			}}})
 			break
 		} else {
 			// TODO Add session-global context here to cancel in-progress operations when the session is closed.
 			requestLogger := s.logger.With(zap.String("cid", request.Cid))
-			processRequest(requestLogger, s, request)
+			if err = processRequest(requestLogger, s, request); err != nil {
+				requestLogger.Warn("Received unrecognized payload", zap.String("data", string(data)))
+				break
+			}
 		}
 	}
 }
@@ -162,12 +166,21 @@ func (s *sessionWS) processOutgoing() {
 				return
 			}
 		case payload := <-s.outgoingCh:
+			s.Lock()
+			if s.stopped {
+				// The connection may have stopped between the payload being queued on the outgoing channel and reaching here.
+				// If that's the case then abort outgoing processing at this point and exit.
+				s.Unlock()
+				return
+			}
 			// Process the outgoing message queue.
 			s.conn.SetWriteDeadline(time.Now().Add(time.Duration(s.config.GetSocket().WriteWaitMs) * time.Millisecond))
 			if err := s.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
+				s.Unlock()
 				s.logger.Warn("Could not write message", zap.Error(err))
 				return
 			}
+			s.Unlock()
 		}
 	}
 }
@@ -229,9 +242,9 @@ func (s *sessionWS) SendBytes(payload []byte) error {
 		// Terminate the connection immediately because the only alternative that doesn't block the server is
 		// to start dropping messages, which might cause unexpected behaviour.
 		s.Unlock()
-		s.logger.Warn("Could not write message, outgoing queue full")
+		s.logger.Warn("Could not write message, session outgoing queue full")
 		s.cleanupClosedConnection()
-		return ErrQueueFull
+		return ErrSessionQueueFull
 	}
 }
 
@@ -244,10 +257,12 @@ func (s *sessionWS) cleanupClosedConnection() {
 	s.stopped = true
 	s.Unlock()
 
-	s.logger.Debug("Cleaning up closed client connection", zap.String("remoteAddress", s.conn.RemoteAddr().String()))
+	if s.logger.Core().Enabled(zap.DebugLevel) {
+		s.logger.Debug("Cleaning up closed client connection", zap.String("remoteAddress", s.conn.RemoteAddr().String()))
+	}
 
 	// When connection close originates internally in the session, ensure cleanup of external resources and references.
-	s.registry.remove(s.id)
+	s.sessionRegistry.remove(s.id)
 	s.tracker.UntrackAll(s.id)
 
 	// Clean up internals.
diff --git a/server/socket_ws.go b/server/socket_ws.go
index f62a2fdd2..9e02c201a 100644
--- a/server/socket_ws.go
+++ b/server/socket_ws.go
@@ -19,11 +19,10 @@ import (
 
 	"github.com/golang/protobuf/jsonpb"
 	"github.com/gorilla/websocket"
-	"github.com/heroiclabs/nakama/rtapi"
 	"go.uber.org/zap"
 )
 
-func NewSocketWsAcceptor(logger *zap.Logger, config Config, registry *SessionRegistry, tracker Tracker, jsonpbMarshaler *jsonpb.Marshaler, jsonpbUnmarshaler *jsonpb.Unmarshaler, processRequest func(*zap.Logger, session, *rtapi.Envelope)) func(http.ResponseWriter, *http.Request) {
+func NewSocketWsAcceptor(logger *zap.Logger, config Config, sessionRegistry *SessionRegistry, tracker Tracker, jsonpbMarshaler *jsonpb.Marshaler, jsonpbUnmarshaler *jsonpb.Unmarshaler, pipeline *pipeline) func(http.ResponseWriter, *http.Request) {
 	upgrader := &websocket.Upgrader{
 		ReadBufferSize:  1024,
 		WriteBufferSize: 1024,
@@ -53,15 +52,15 @@ func NewSocketWsAcceptor(logger *zap.Logger, config Config, registry *SessionReg
 		}
 
 		// Wrap the connection for application handling.
-		s := NewSessionWS(logger, config, userID, username, expiry, jsonpbMarshaler, jsonpbUnmarshaler, conn, registry, tracker)
+		s := NewSessionWS(logger, config, userID, username, expiry, jsonpbMarshaler, jsonpbUnmarshaler, conn, sessionRegistry, tracker)
 
 		// Add to the session registry.
-		registry.add(s)
+		sessionRegistry.add(s)
 
 		// Register initial presences for this session.
-		tracker.Track(s.ID(), PresenceStream{Mode: StreamModeNotifications, Subject: s.UserID()}, s.UserID(), PresenceMeta{Format: s.Format(), Username: s.Username()})
+		tracker.Track(s.ID(), PresenceStream{Mode: StreamModeNotifications, Subject: s.UserID()}, s.UserID(), PresenceMeta{Format: s.Format(), Username: s.Username(), Hidden: true}, true)
 
 		// Allow the server to begin processing incoming messages from this session.
-		s.Consume(processRequest)
+		s.Consume(pipeline.processRequest)
 	}
 }
diff --git a/server/tracker.go b/server/tracker.go
index ba50e919f..f1d2b6b01 100644
--- a/server/tracker.go
+++ b/server/tracker.go
@@ -17,6 +17,7 @@ package server
 import (
 	"sync"
 
+	"fmt"
 	"github.com/golang/protobuf/jsonpb"
 	"github.com/heroiclabs/nakama/rtapi"
 	"github.com/satori/go.uuid"
@@ -33,6 +34,8 @@ const (
 	StreamModeMatchAuthoritative
 )
 
+const EventsQueueSize = 512
+
 type PresenceID struct {
 	Node      string
 	SessionID uuid.UUID
@@ -66,10 +69,11 @@ type PresenceEvent struct {
 }
 
 type Tracker interface {
+	SetMatchLeaveListener(func(id uuid.UUID, leaves []*MatchPresence))
 	Stop()
 
-	// Individual presence and user operations.
-	Track(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID, meta PresenceMeta) bool
+	// Track returns success true/false, and new presence true/false.
+	Track(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID, meta PresenceMeta, allowIfFirstForSession bool) (bool, bool)
 	Untrack(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID)
 	UntrackAll(sessionID uuid.UUID)
 	Update(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID, meta PresenceMeta) bool
@@ -88,12 +92,17 @@ type Tracker interface {
 	Count() int
 	// Get the number of presences in the given stream.
 	CountByStream(stream PresenceStream) int
+	// Get a snapshot of current presence counts for streams with one of the given stream modes.
+	CountByStreamModeFilter(modes map[uint8]*uint8) map[*PresenceStream]int32
 	// Check if a single presence on the current node exists.
 	GetLocalBySessionIDStreamUserID(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID) *PresenceMeta
 	// List presences by stream.
-	ListByStream(stream PresenceStream) []Presence
-	// List presences on the current node by stream.
-	ListLocalByStream(stream PresenceStream) []Presence
+	ListByStream(stream PresenceStream) []*Presence
+
+	// Fast lookup of local session IDs to use for message delivery.
+	ListLocalSessionIDByStream(stream PresenceStream) []uuid.UUID
+	// Fast lookup of node + session IDs to use for message delivery.
+	ListPresenceIDByStream(stream PresenceStream) []*PresenceID
 }
 
 type presenceCompact struct {
@@ -105,24 +114,25 @@ type presenceCompact struct {
 type LocalTracker struct {
 	sync.RWMutex
 	logger             *zap.Logger
-	registry           *SessionRegistry
+	matchLeaveListener func(id uuid.UUID, leaves []*MatchPresence)
+	sessionRegistry    *SessionRegistry
 	jsonpbMarshaler    *jsonpb.Marshaler
 	name               string
 	eventsCh           chan *PresenceEvent
 	stopCh             chan struct{}
-	presencesByStream  map[PresenceStream]map[presenceCompact]PresenceMeta
+	presencesByStream  map[uint8]map[PresenceStream]map[presenceCompact]PresenceMeta
 	presencesBySession map[uuid.UUID]map[presenceCompact]PresenceMeta
 }
 
-func StartLocalTracker(logger *zap.Logger, registry *SessionRegistry, jsonpbMarshaler *jsonpb.Marshaler, name string) Tracker {
+func StartLocalTracker(logger *zap.Logger, sessionRegistry *SessionRegistry, jsonpbMarshaler *jsonpb.Marshaler, name string) Tracker {
 	t := &LocalTracker{
 		logger:             logger,
-		registry:           registry,
+		sessionRegistry:    sessionRegistry,
 		jsonpbMarshaler:    jsonpbMarshaler,
 		name:               name,
-		eventsCh:           make(chan *PresenceEvent, 128),
+		eventsCh:           make(chan *PresenceEvent, EventsQueueSize),
 		stopCh:             make(chan struct{}),
-		presencesByStream:  make(map[PresenceStream]map[presenceCompact]PresenceMeta),
+		presencesByStream:  make(map[uint8]map[PresenceStream]map[presenceCompact]PresenceMeta),
 		presencesBySession: make(map[uuid.UUID]map[presenceCompact]PresenceMeta),
 	}
 	go func() {
@@ -139,47 +149,58 @@ func StartLocalTracker(logger *zap.Logger, registry *SessionRegistry, jsonpbMars
 	return t
 }
 
+func (t *LocalTracker) SetMatchLeaveListener(f func(id uuid.UUID, leaves []*MatchPresence)) {
+	t.matchLeaveListener = f
+}
+
 func (t *LocalTracker) Stop() {
 	// No need to explicitly clean up the events channel, just let the application exit.
 	close(t.stopCh)
 }
 
-func (t *LocalTracker) Track(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID, meta PresenceMeta) bool {
+func (t *LocalTracker) Track(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID, meta PresenceMeta, allowIfFirstForSession bool) (bool, bool) {
 	pc := presenceCompact{ID: PresenceID{Node: t.name, SessionID: sessionID}, Stream: stream, UserID: userID}
-	alreadyTracked := false
 	t.Lock()
 
 	// See if this session has any presences tracked at all.
-	bySession, anyTracked := t.presencesBySession[sessionID]
-	if anyTracked {
+	if bySession, anyTracked := t.presencesBySession[sessionID]; anyTracked {
 		// Then see if the exact presence we need is tracked.
-		_, alreadyTracked = bySession[pc]
-	}
-
-	// Maybe update tracking for session.
-	if !anyTracked {
+		if _, alreadyTracked := bySession[pc]; !alreadyTracked {
+			// If the current session had others tracked, but not this presence.
+			bySession[pc] = meta
+		} else {
+			t.Unlock()
+			return true, false
+		}
+	} else {
+		if !allowIfFirstForSession {
+			// If it's the first presence for this session, only allow it if explicitly permitted to.
+			t.Unlock()
+			return false, false
+		}
 		// If nothing at all was tracked for the current session, begin tracking.
 		bySession = make(map[presenceCompact]PresenceMeta)
 		bySession[pc] = meta
 		t.presencesBySession[sessionID] = bySession
-	} else if !alreadyTracked {
-		// If the current session had others tracked, but not this presence.
-		bySession[pc] = meta
 	}
 
-	// Maybe update tracking for stream.
-	if !alreadyTracked {
-		if byStream, ok := t.presencesByStream[stream]; !ok {
-			byStream = make(map[presenceCompact]PresenceMeta)
-			byStream[pc] = meta
-			t.presencesByStream[stream] = byStream
-		} else {
-			byStream[pc] = meta
-		}
+	// Update tracking for stream.
+	byStreamMode, ok := t.presencesByStream[stream.Mode]
+	if !ok {
+		byStreamMode = make(map[PresenceStream]map[presenceCompact]PresenceMeta)
+		t.presencesByStream[stream.Mode] = byStreamMode
+	}
+
+	if byStream, ok := byStreamMode[stream]; !ok {
+		byStream = make(map[presenceCompact]PresenceMeta)
+		byStream[pc] = meta
+		byStreamMode[stream] = byStream
+	} else {
+		byStream[pc] = meta
 	}
 
 	t.Unlock()
-	if !alreadyTracked && !meta.Hidden {
+	if !meta.Hidden {
 		t.queueEvent(
 			[]Presence{
 				Presence{ID: pc.ID, Stream: stream, UserID: userID, Meta: meta},
@@ -187,7 +208,7 @@ func (t *LocalTracker) Track(sessionID uuid.UUID, stream PresenceStream, userID
 			nil,
 		)
 	}
-	return !alreadyTracked
+	return true, true
 }
 
 func (t *LocalTracker) Untrack(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID) {
@@ -217,12 +238,24 @@ func (t *LocalTracker) Untrack(sessionID uuid.UUID, stream PresenceStream, userI
 	}
 
 	// Update the tracking for stream.
-	if byStream := t.presencesByStream[stream]; len(byStream) == 1 {
-		// This was the only presence for the stream, discard the whole list.
-		delete(t.presencesByStream, stream)
+	if byStreamMode := t.presencesByStream[stream.Mode]; len(byStreamMode) == 1 {
+		// This is the only stream for this stream mode.
+		if byStream := byStreamMode[stream]; len(byStream) == 1 {
+			// This was the only presence in the only stream for this stream mode, discard the whole list.
+			delete(t.presencesByStream, stream.Mode)
+		} else {
+			// There were other presences for the stream, drop just this one.
+			delete(byStream, pc)
+		}
 	} else {
-		// There were other presences for the stream, drop just this one.
-		delete(byStream, pc)
+		// There are other streams for this stream mode.
+		if byStream := byStreamMode[stream]; len(byStream) == 1 {
+			// This was the only presence for the stream, discard the whole list.
+			delete(byStreamMode, stream)
+		} else {
+			// There were other presences for the stream, drop just this one.
+			delete(byStream, pc)
+		}
 	}
 
 	t.Unlock()
@@ -249,12 +282,24 @@ func (t *LocalTracker) UntrackAll(sessionID uuid.UUID) {
 	leaves := make([]Presence, 0, len(bySession))
 	for pc, meta := range bySession {
 		// Update the tracking for stream.
-		if byStream := t.presencesByStream[pc.Stream]; len(byStream) == 1 {
-			// This was the only presence for the stream, discard the whole list.
-			delete(t.presencesByStream, pc.Stream)
+		if byStreamMode := t.presencesByStream[pc.Stream.Mode]; len(byStreamMode) == 1 {
+			// This is the only stream for this stream mode.
+			if byStream := byStreamMode[pc.Stream]; len(byStream) == 1 {
+				// This was the only presence in the only stream for this stream mode, discard the whole list.
+				delete(t.presencesByStream, pc.Stream.Mode)
+			} else {
+				// There were other presences for the stream, drop just this one.
+				delete(byStream, pc)
+			}
 		} else {
-			// There were other presences for the stream, drop just this one.
-			delete(byStream, pc)
+			// There are other streams for this stream mode.
+			if byStream := byStreamMode[pc.Stream]; len(byStream) == 1 {
+				// This was the only presence for the stream, discard the whole list.
+				delete(byStreamMode, pc.Stream)
+			} else {
+				// There were other presences for the stream, drop just this one.
+				delete(byStream, pc)
+			}
 		}
 
 		// Check if there should be an event for this presence.
@@ -294,7 +339,7 @@ func (t *LocalTracker) Update(sessionID uuid.UUID, stream PresenceStream, userID
 	// Update the tracking for session.
 	bySession[pc] = meta
 	// Update the tracking for stream.
-	t.presencesByStream[stream][pc] = meta
+	t.presencesByStream[stream.Mode][stream][pc] = meta
 
 	t.Unlock()
 	if !meta.Hidden || !previousMeta.Hidden {
@@ -322,7 +367,7 @@ func (t *LocalTracker) UntrackLocalByStream(stream PresenceStream) {
 	// NOTE: Generates no presence notifications as everyone on the stream is going away all at once.
 	t.Lock()
 
-	byStream, anyTracked := t.presencesByStream[stream]
+	byStream, anyTracked := t.presencesByStream[stream.Mode][stream]
 	if !anyTracked {
 		// Nothing tracked for the stream.
 		t.Unlock()
@@ -339,8 +384,15 @@ func (t *LocalTracker) UntrackLocalByStream(stream PresenceStream) {
 			delete(bySession, pc)
 		}
 	}
+
 	// Discard the tracking for stream.
-	delete(t.presencesByStream, stream)
+	if byStreamMode := t.presencesByStream[stream.Mode]; len(byStreamMode) == 1 {
+		// This is the only stream for this stream mode.
+		delete(t.presencesByStream, stream.Mode)
+	} else {
+		// There are other streams for this stream mode.
+		delete(byStreamMode, stream)
+	}
 
 	t.Unlock()
 }
@@ -349,7 +401,7 @@ func (t *LocalTracker) UntrackByStream(stream PresenceStream) {
 	// NOTE: Generates no presence notifications as everyone on the stream is going away all at once.
 	t.Lock()
 
-	byStream, anyTracked := t.presencesByStream[stream]
+	byStream, anyTracked := t.presencesByStream[stream.Mode][stream]
 	if !anyTracked {
 		// Nothing tracked for the stream.
 		t.Unlock()
@@ -366,15 +418,22 @@ func (t *LocalTracker) UntrackByStream(stream PresenceStream) {
 			delete(bySession, pc)
 		}
 	}
+
 	// Discard the tracking for stream.
-	delete(t.presencesByStream, stream)
+	if byStreamMode := t.presencesByStream[stream.Mode]; len(byStreamMode) == 1 {
+		// This is the only stream for this stream mode.
+		delete(t.presencesByStream, stream.Mode)
+	} else {
+		// There are other streams for this stream mode.
+		delete(byStreamMode, stream)
+	}
 
 	t.Unlock()
 }
 
 func (t *LocalTracker) ListNodesForStream(stream PresenceStream) []string {
 	t.RLock()
-	_, anyTracked := t.presencesByStream[stream]
+	_, anyTracked := t.presencesByStream[stream.Mode][stream]
 	t.RUnlock()
 	if anyTracked {
 		// For the local tracker having any presences for this stream is enough.
@@ -386,7 +445,7 @@ func (t *LocalTracker) ListNodesForStream(stream PresenceStream) []string {
 func (t *LocalTracker) StreamExists(stream PresenceStream) bool {
 	var exists bool
 	t.RLock()
-	exists = t.presencesByStream[stream] != nil
+	exists = t.presencesByStream[stream.Mode][stream] != nil
 	t.RUnlock()
 	return exists
 }
@@ -394,9 +453,9 @@ func (t *LocalTracker) StreamExists(stream PresenceStream) bool {
 func (t *LocalTracker) Count() int {
 	var count int
 	t.RLock()
-	// For each stream add together their presence count.
-	for _, byStream := range t.presencesByStream {
-		count += len(byStream)
+	// For each session add together their presence count.
+	for _, bySession := range t.presencesBySession {
+		count += len(bySession)
 	}
 	t.RUnlock()
 	return count
@@ -406,14 +465,28 @@ func (t *LocalTracker) CountByStream(stream PresenceStream) int {
 	var count int
 	t.RLock()
 	// If the stream exists use its presence count, otherwise 0.
-	byStream, anyTracked := t.presencesByStream[stream]
-	if anyTracked {
+	if byStream, anyTracked := t.presencesByStream[stream.Mode][stream]; anyTracked {
 		count = len(byStream)
 	}
 	t.RUnlock()
 	return count
 }
 
+func (t *LocalTracker) CountByStreamModeFilter(modes map[uint8]*uint8) map[*PresenceStream]int32 {
+	counts := make(map[*PresenceStream]int32)
+	t.RLock()
+	for mode, byStreamMode := range t.presencesByStream {
+		if modes[mode] == nil {
+			continue
+		}
+		for s, ps := range byStreamMode {
+			counts[&s] = int32(len(ps))
+		}
+	}
+	t.RUnlock()
+	return counts
+}
+
 func (t *LocalTracker) GetLocalBySessionIDStreamUserID(sessionID uuid.UUID, stream PresenceStream, userID uuid.UUID) *PresenceMeta {
 	pc := presenceCompact{ID: PresenceID{Node: t.name, SessionID: sessionID}, Stream: stream, UserID: userID}
 	t.RLock()
@@ -431,31 +504,47 @@ func (t *LocalTracker) GetLocalBySessionIDStreamUserID(sessionID uuid.UUID, stre
 	return &meta
 }
 
-func (t *LocalTracker) ListByStream(stream PresenceStream) []Presence {
+func (t *LocalTracker) ListByStream(stream PresenceStream) []*Presence {
 	t.RLock()
-	byStream, anyTracked := t.presencesByStream[stream]
+	byStream, anyTracked := t.presencesByStream[stream.Mode][stream]
 	if !anyTracked {
 		t.RUnlock()
-		return []Presence{}
+		return []*Presence{}
 	}
-	ps := make([]Presence, 0, len(byStream))
+	ps := make([]*Presence, 0, len(byStream))
 	for pc, meta := range byStream {
-		ps = append(ps, Presence{ID: pc.ID, Stream: stream, UserID: pc.UserID, Meta: meta})
+		ps = append(ps, &Presence{ID: pc.ID, Stream: stream, UserID: pc.UserID, Meta: meta})
 	}
 	t.RUnlock()
 	return ps
 }
 
-func (t *LocalTracker) ListLocalByStream(stream PresenceStream) []Presence {
+func (t *LocalTracker) ListLocalSessionIDByStream(stream PresenceStream) []uuid.UUID {
 	t.RLock()
-	byStream, anyTracked := t.presencesByStream[stream]
+	byStream, anyTracked := t.presencesByStream[stream.Mode][stream]
 	if !anyTracked {
 		t.RUnlock()
-		return []Presence{}
+		return []uuid.UUID{}
 	}
-	ps := make([]Presence, 0, len(byStream))
-	for pc, meta := range byStream {
-		ps = append(ps, Presence{ID: pc.ID, Stream: stream, UserID: pc.UserID, Meta: meta})
+	ps := make([]uuid.UUID, 0, len(byStream))
+	for pc, _ := range byStream {
+		ps = append(ps, pc.ID.SessionID)
+	}
+	t.RUnlock()
+	return ps
+}
+
+func (t *LocalTracker) ListPresenceIDByStream(stream PresenceStream) []*PresenceID {
+	t.RLock()
+	byStream, anyTracked := t.presencesByStream[stream.Mode][stream]
+	if !anyTracked {
+		t.RUnlock()
+		return []*PresenceID{}
+	}
+	ps := make([]*PresenceID, 0, len(byStream))
+	for pc, _ := range byStream {
+		pid := pc.ID
+		ps = append(ps, &pid)
 	}
 	t.RUnlock()
 	return ps
@@ -481,12 +570,18 @@ func (t *LocalTracker) queueEvent(joins, leaves []Presence) {
 }
 
 func (t *LocalTracker) processEvent(e *PresenceEvent) {
-	t.logger.Debug("Processing presence event", zap.Int("joins", len(e.joins)), zap.Int("leaves", len(e.leaves)))
+	if t.logger.Core().Enabled(zap.DebugLevel) {
+		t.logger.Debug("Processing presence event", zap.Int("joins", len(e.joins)), zap.Int("leaves", len(e.leaves)))
+	}
 
 	// Group joins/leaves by stream to allow batching.
 	// Convert to wire representation at the same time.
 	streamJoins := make(map[PresenceStream][]*rtapi.StreamPresence, 0)
 	streamLeaves := make(map[PresenceStream][]*rtapi.StreamPresence, 0)
+
+	// Track grouped authoritative match leaves separately from client-bound events.
+	matchLeaves := make(map[uuid.UUID][]*MatchPresence, 0)
+
 	for _, p := range e.joins {
 		pWire := &rtapi.StreamPresence{
 			UserId:      p.UserID.String(),
@@ -509,11 +604,31 @@ func (t *LocalTracker) processEvent(e *PresenceEvent) {
 			Persistence: p.Meta.Persistence,
 			Status:      p.Meta.Status,
 		}
-		if j, ok := streamLeaves[p.Stream]; ok {
-			streamLeaves[p.Stream] = append(j, pWire)
+		if l, ok := streamLeaves[p.Stream]; ok {
+			streamLeaves[p.Stream] = append(l, pWire)
 		} else {
 			streamLeaves[p.Stream] = []*rtapi.StreamPresence{pWire}
 		}
+
+		// We only care about authoritative match leaves where the match host is the current node.
+		if p.Stream.Mode == StreamModeMatchAuthoritative && p.Stream.Label == t.name {
+			mp := &MatchPresence{
+				Node:      p.ID.Node,
+				UserID:    p.UserID,
+				SessionID: p.ID.SessionID,
+				Username:  p.Meta.Username,
+			}
+			if l, ok := matchLeaves[p.Stream.Subject]; ok {
+				matchLeaves[p.Stream.Subject] = append(l, mp)
+			} else {
+				matchLeaves[p.Stream.Subject] = []*MatchPresence{mp}
+			}
+		}
+	}
+
+	// Notify locally hosted authoritative matches of leave events.
+	for matchID, leaves := range matchLeaves {
+		t.matchLeaveListener(matchID, leaves)
 	}
 
 	// Send joins, together with any leaves for the same topic.
@@ -535,13 +650,30 @@ func (t *LocalTracker) processEvent(e *PresenceEvent) {
 			streamWire.Descriptor_ = stream.Descriptor.String()
 		}
 
-		// Construct the wire representation of the event.
-		envelope := &rtapi.Envelope{Message: &rtapi.Envelope_StreamPresenceEvent{StreamPresenceEvent: &rtapi.StreamPresenceEvent{
-			Stream: streamWire,
-			Joins:  joins,
-			Leaves: leaves,
-		},
-		}}
+		// Find the list of event recipients first so we can skip event encoding work if it's not necessary.
+		sessionIDs := t.ListLocalSessionIDByStream(stream)
+		if len(sessionIDs) == 0 {
+			continue
+		}
+
+		// Construct the wire representation of the event based on the stream mode.
+		var envelope *rtapi.Envelope
+		switch stream.Mode {
+		case StreamModeMatchRelayed:
+			fallthrough
+		case StreamModeMatchAuthoritative:
+			envelope = &rtapi.Envelope{Message: &rtapi.Envelope_MatchPresenceEvent{MatchPresenceEvent: &rtapi.MatchPresenceEvent{
+				MatchId: fmt.Sprintf("%v:%v", stream.Subject.String(), stream.Label),
+				Joins:   joins,
+				Leaves:  leaves,
+			}}}
+		default:
+			envelope = &rtapi.Envelope{Message: &rtapi.Envelope_StreamPresenceEvent{StreamPresenceEvent: &rtapi.StreamPresenceEvent{
+				Stream: streamWire,
+				Joins:  joins,
+				Leaves: leaves,
+			}}}
+		}
 		payload, err := t.jsonpbMarshaler.MarshalToString(envelope)
 		if err != nil {
 			t.logger.Warn("Could not marshal presence event to json", zap.Error(err))
@@ -549,14 +681,12 @@ func (t *LocalTracker) processEvent(e *PresenceEvent) {
 		}
 		payloadByte := []byte(payload)
 
-		// Find the list of event recipients.
-		presences := t.ListLocalByStream(stream)
-		for _, p := range presences {
-			// Deliver event.
-			if s := t.registry.Get(p.ID.SessionID); s != nil {
+		// Deliver event.
+		for _, sessionID := range sessionIDs {
+			if s := t.sessionRegistry.Get(sessionID); s != nil {
 				s.SendBytes(payloadByte)
-			} else {
-				t.logger.Warn("Could not deliver presence event, no session", zap.String("sid", p.ID.SessionID.String()))
+			} else if t.logger.Core().Enabled(zap.DebugLevel) {
+				t.logger.Debug("Could not deliver presence event, no session", zap.String("sid", sessionID.String()))
 			}
 		}
 	}
@@ -575,13 +705,30 @@ func (t *LocalTracker) processEvent(e *PresenceEvent) {
 			streamWire.Descriptor_ = stream.Descriptor.String()
 		}
 
-		// Construct the wire representation of the event.
-		envelope := &rtapi.Envelope{Message: &rtapi.Envelope_StreamPresenceEvent{StreamPresenceEvent: &rtapi.StreamPresenceEvent{
-			Stream: streamWire,
-			// No joins.
-			Leaves: leaves,
-		},
-		}}
+		// Find the list of event recipients first so we can skip event encoding work if it's not necessary.
+		sessionIDs := t.ListLocalSessionIDByStream(stream)
+		if len(sessionIDs) == 0 {
+			continue
+		}
+
+		// Construct the wire representation of the event based on the stream mode.
+		var envelope *rtapi.Envelope
+		switch stream.Mode {
+		case StreamModeMatchRelayed:
+			fallthrough
+		case StreamModeMatchAuthoritative:
+			envelope = &rtapi.Envelope{Message: &rtapi.Envelope_MatchPresenceEvent{MatchPresenceEvent: &rtapi.MatchPresenceEvent{
+				MatchId: fmt.Sprintf("%v:%v", stream.Subject.String(), stream.Label),
+				// No joins.
+				Leaves: leaves,
+			}}}
+		default:
+			envelope = &rtapi.Envelope{Message: &rtapi.Envelope_StreamPresenceEvent{StreamPresenceEvent: &rtapi.StreamPresenceEvent{
+				Stream: streamWire,
+				// No joins.
+				Leaves: leaves,
+			}}}
+		}
 		payload, err := t.jsonpbMarshaler.MarshalToString(envelope)
 		if err != nil {
 			t.logger.Warn("Could not marshal presence event to json", zap.Error(err))
@@ -589,14 +736,12 @@ func (t *LocalTracker) processEvent(e *PresenceEvent) {
 		}
 		payloadByte := []byte(payload)
 
-		// Find the list of event recipients.
-		presences := t.ListLocalByStream(stream)
-		for _, p := range presences {
-			// Deliver event.
-			if s := t.registry.Get(p.ID.SessionID); s != nil {
+		// Deliver event.
+		for _, sessionID := range sessionIDs {
+			if s := t.sessionRegistry.Get(sessionID); s != nil {
 				s.SendBytes(payloadByte)
-			} else {
-				t.logger.Warn("Could not deliver presence event, no session", zap.String("sid", p.ID.SessionID.String()))
+			} else if t.logger.Core().Enabled(zap.DebugLevel) {
+				t.logger.Debug("Could not deliver presence event, no session", zap.String("sid", sessionID.String()))
 			}
 		}
 	}
-- 
GitLab