Skip to content

Custom gRPC Adapters

This guide shows you how to create custom gRPC adapters for Passage, enabling you to implement complex routing logic, integrate with existing systems, and extend Passage’s functionality using any programming language.

gRPC adapters allow you to:

  • Implement custom business logic in any language with gRPC support
  • Integrate with existing infrastructure (databases, APIs, services)
  • Maintain separation of concerns between Passage and your application logic
  • Scale independently from Passage
  • Basic understanding of gRPC and Protocol Buffers
  • A programming language with gRPC support (Go, Java, Python, Node.js, Rust, etc.)
  • The Passage proto definitions

Passage provides proto definitions for all six gRPC services. These are available in the repository:

  • Directorypassage-adapters/grpc/proto/adapter/
    • adapter.proto Common types (Target, Address, MetaEntry, Profile, ClientInfo, PlayerInfo)
    • status.proto Status service
    • authentication.proto Authentication service
    • discovery.proto Discovery service
    • discovery_action.proto Discovery Action service
    • localization.proto Localization service

Passage uses gRPC in four adapter categories. Each service handles a different phase of the connection:

ServiceProto FileConfig typePurpose
Statusstatus.protogrpc (in status)Server list ping response
Authenticationauthentication.protogrpc (in authentication)Player identity verification
Discoverydiscovery.protogrpc_discovery (in discovery)Backend server discovery
DiscoveryActiondiscovery_action.protogrpc (in discovery.actions)Target list transformation
Localizationlocalization.protogrpc (in localization)Disconnect message translation

See the gRPC Protocol Reference for complete message definitions.


  1. Set up your project and generate code from the proto files

    Terminal window
    mkdir passage-status-adapter && cd passage-status-adapter
    go mod init github.com/yourorg/passage-status-adapter
    # Copy protos from the Passage repository
    mkdir -p proto/adapter
    cp /path/to/passage/passage-adapters/grpc/proto/adapter/*.proto proto/adapter/
    # Generate Go code
    protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    proto/adapter/*.proto
  2. Implement the Status service

    package main
    import (
    "context"
    "log"
    "net"
    pb "github.com/yourorg/passage-status-adapter/proto/adapter"
    "google.golang.org/grpc"
    )
    type statusServer struct {
    pb.UnimplementedStatusServer
    }
    func (s *statusServer) GetStatus(ctx context.Context, req *pb.StatusRequest) (*pb.StatusResponse, error) {
    return &pb.StatusResponse{
    Status: &pb.StatusData{
    Version: &pb.ProtocolVersion{
    Name: "My Network",
    Protocol: int32(req.Protocol),
    },
    Players: &pb.Players{
    Online: 42,
    Max: 100,
    },
    Description: stringPtr(`{"text":"Welcome!","color":"gold"}`),
    },
    }, nil
    }
    func stringPtr(s string) *string { return &s }
    func main() {
    lis, _ := net.Listen("tcp", ":50051")
    s := grpc.NewServer()
    pb.RegisterStatusServer(s, &statusServer{})
    log.Println("Status adapter listening on :50051")
    s.Serve(lis)
    }
  3. Configure Passage

    routes:
    - hostname: "mc.example.net"
    status:
    type: grpc
    address: "http://localhost:50051"

Implement a discovery adapter that returns servers from your infrastructure:

server.py
import grpc
from concurrent import futures
import adapter.discovery_pb2 as discovery_pb2
import adapter.discovery_pb2_grpc as discovery_grpc
import adapter.adapter_pb2 as adapter_pb2
class DiscoveryService(discovery_grpc.DiscoveryServicer):
def GetTargets(self, request, context):
# request.client contains ClientInfo (client_address, server_address, protocol_version)
servers = [
{"id": "hub-1", "host": "10.0.1.10", "port": 25565,
"meta": {"type": "hub", "players": "15", "status": "online"}},
{"id": "hub-2", "host": "10.0.1.11", "port": 25565,
"meta": {"type": "hub", "players": "38", "status": "online"}},
]
targets = []
for s in servers:
targets.append(adapter_pb2.Target(
identifier=s["id"],
address=adapter_pb2.Address(hostname=s["host"], port=s["port"]),
meta=[adapter_pb2.MetaEntry(key=k, value=v) for k, v in s["meta"].items()],
))
return discovery_pb2.TargetsResponse(targets=targets)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
discovery_grpc.add_DiscoveryServicer_to_server(DiscoveryService(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()

Configuration:

routes:
- hostname: "mc.example.net"
discovery:
type: grpc_discovery
address: "http://localhost:50051"

Example: Discovery Action with Database Routing

Section titled “Example: Discovery Action with Database Routing”

A discovery action adapter that routes players based on database preferences:

package main
import (
"context"
"database/sql"
"log"
"net"
pb "github.com/yourorg/passage-router/proto/adapter"
_ "github.com/lib/pq"
"google.golang.org/grpc"
)
type routerServer struct {
pb.UnimplementedDiscoveryActionServer
db *sql.DB
}
func (s *routerServer) Apply(ctx context.Context, req *pb.ApplyRequest) (*pb.ApplyResponse, error) {
// Get player's preferred region from database
var preferredRegion string
err := s.db.QueryRowContext(ctx,
"SELECT preferred_region FROM player_preferences WHERE uuid = $1",
req.Player.Id,
).Scan(&preferredRegion)
if err != nil {
// No preference found, return all targets unchanged
return &pb.ApplyResponse{
Reason: &pb.ApplyResponse_Targets{
Targets: &pb.Targets{Targets: req.Targets},
},
}, nil
}
// Filter to preferred region
var filtered []*pb.Target
for _, target := range req.Targets {
if getMetadata(target, "region") == preferredRegion {
filtered = append(filtered, target)
}
}
// Fall back to all targets if none match
if len(filtered) == 0 {
filtered = req.Targets
}
return &pb.ApplyResponse{
Reason: &pb.ApplyResponse_Targets{
Targets: &pb.Targets{Targets: filtered},
},
}, nil
}
func getMetadata(target *pb.Target, key string) string {
for _, entry := range target.Meta {
if entry.Key == key {
return entry.Value
}
}
return ""
}
func main() {
db, _ := sql.Open("postgres", "postgresql://user:pass@localhost/passage?sslmode=disable")
defer db.Close()
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterDiscoveryActionServer(s, &routerServer{db: db})
log.Println("Router adapter listening on :50051")
s.Serve(lis)
}

Configuration:

routes:
- hostname: "mc.example.net"
discovery:
type: dns_discovery
domain: "servers.example.net"
record_type: srv
actions:
- type: meta_filter
name: "online-filter"
rules:
- key: "status"
op: equals
value: "online"
- type: grpc
name: "region-router"
address: "http://localhost:50051"
- type: player_fill_strategy
name: "fill"
field: "players"
max_players: 50

A custom authentication adapter for integration with your own account system:

func (s *authServer) Authenticate(ctx context.Context, req *pb.AuthenticationRequest) (*pb.AuthenticationResponse, error) {
// req.Player contains name and id
// req.SharedSecret and req.EncodedPublic contain encryption data
// req.Client contains client/server addresses and protocol version
// Verify the player against your account system
profile, err := s.verifyPlayer(ctx, req.Player.Name, req.SharedSecret)
if err != nil {
// Reject: return a localization key for the disconnect message
return &pb.AuthenticationResponse{
Reason: &pb.AuthenticationResponse_Key{
Key: "disconnect_unauthenticated",
},
}, nil
}
// Accept: return the player's profile
return &pb.AuthenticationResponse{
Reason: &pb.AuthenticationResponse_Profile{
Profile: &pb.Profile{
Id: profile.UUID,
Name: profile.Name,
Properties: []*pb.ProfileProperty{
{Name: "textures", Value: profile.TextureData, Signature: &profile.Signature},
},
},
},
}, nil
}

Configuration:

routes:
- hostname: "mc.example.net"
authentication:
type: grpc
address: "http://localhost:50051"

Both the Authentication and DiscoveryAction services can reject players by returning a key instead of a successful response. The key is a localization key that Passage resolves using the route’s localization adapter:

// Reject in DiscoveryAction
return &pb.ApplyResponse{
Reason: &pb.ApplyResponse_Key{Key: "disconnect_queue_full"},
}, nil
// Reject in Authentication
return &pb.AuthenticationResponse{
Reason: &pb.AuthenticationResponse_Key{Key: "disconnect_unauthenticated"},
}, nil

The player sees the localized message for that key in their client language.


Test your adapters using grpcurl:

Terminal window
# Test Status adapter
grpcurl -plaintext -import-path ./proto -proto adapter/status.proto \
-d '{"client_address":{"hostname":"127.0.0.1","port":12345},"server_address":{"hostname":"localhost","port":25565},"protocol":769}' \
localhost:50051 scrayosnet.passage.adapter.Status/GetStatus
# Test Discovery adapter
grpcurl -plaintext -import-path ./proto -proto adapter/discovery.proto \
-d '{"client":{"client_address":{"hostname":"127.0.0.1","port":12345},"server_address":{"hostname":"localhost","port":25565},"protocol_version":769}}' \
localhost:50051 scrayosnet.passage.adapter.Discovery/GetTargets
# Test DiscoveryAction adapter
grpcurl -plaintext -import-path ./proto -proto adapter/discovery_action.proto \
-d '{"client":{"client_address":{"hostname":"127.0.0.1","port":12345}},"player":{"name":"Steve","id":"069a79f4-44e9-4726-a5be-fca90e38aaf5"},"targets":[{"identifier":"hub-1","address":{"hostname":"10.0.1.10","port":25565}}]}' \
localhost:50051 scrayosnet.passage.adapter.DiscoveryAction/Apply
  • Keep response times under 50ms — slow adapters delay player connections
  • Use connection pooling for database and HTTP connections
  • Cache expensive lookups where possible
  • Always return a response — never leave Passage waiting
  • Handle errors gracefully — return default values or reject with a meaningful localization key
  • Implement health checks using the gRPC health checking protocol
  • Run close to Passage to minimize network latency
  • Use TLS (https:// addresses) in production
  • Monitor response times with your observability stack

Enable Passage debug logs to see adapter communication:

Terminal window
RUST_LOG=passage=debug passage