Skip to content

oriumgames/pecs

Repository files navigation

PECS

Player Event Component System for Dragonfly

PECS is a game architecture framework designed for Dragonfly Minecraft servers. It provides structured game logic through handlers, components, systems, and dependency injection while respecting Dragonfly's transaction-based world model.

Table of Contents


Installation

go get github.com/oriumgames/pecs

Quick Start

package main

import (
    "time"
    "github.com/df-mc/dragonfly/server"
    "github.com/df-mc/dragonfly/server/player"
    "github.com/oriumgames/pecs"
)

// Components are plain structs
type Health struct {
    Current int
    Max     int
}

// Handlers respond to player events
type DamageHandler struct {
    Session *pecs.Session
    Health  *Health `pecs:"mut"`
}

func (h *DamageHandler) HandleHurt(ev *pecs.EventHurt) {
    h.Health.Current -= int(*ev.Damage)
}

// Loops run at fixed intervals
type RegenLoop struct {
    Session *pecs.Session
    Health  *Health `pecs:"mut"`
}

func (l *RegenLoop) Run(tx *world.Tx) {
    if l.Health.Current < l.Health.Max {
        l.Health.Current++
    }
}

func main() {
    // Create a bundle for your game logic
    bundle := pecs.NewBundle("gameplay").
        Handler(&DamageHandler{}).
        Loop(&RegenLoop{}, time.Second, pecs.Default).
        Build()

    // Initialize PECS
    mngr := pecs.NewBuilder().
        Bundle(bundle).
        Init()

    // Start your Dragonfly server
    srv := server.New()
    srv.Listen()

    for p := range srv.Accept() {
        sess, err := mngr.NewSession(p)
        if err != nil {
            p.Disconnect("failed to initialize session")
            continue
        }

        pecs.Add(sess, &Health{Current: 20, Max: 20})
        p.Handle(pecs.NewHandler(sess, p))
    }
}

Core Concepts

Sessions

Sessions wrap Dragonfly players with persistent identity and component storage. They survive world transfers and provide thread-safe component access.

// Create session when player joins
sess := mngr.NewSession(p)

// Retrieve session later
sess := mngr.GetSession(p)
sess := mngr.GetSessionByUUID(uuid)
sess := mngr.GetSessionByName("PlayerName")
sess := mngr.GetSessionByID(xuid)

// Get persistent identifier (XUID) - used for cross-server references
id := sess.ID()

// Access player within transaction context
if p, ok := sess.Player(tx); ok {
    p.Message("Hello!")
}

// Execute code in player's world transaction
sess.Exec(func(tx *world.Tx, p *player.Player) {
    p.Heal(10, healing.SourceFood{})
})

// Check session state
if sess.Closed() {
    return
}

// Get the current world
world := sess.World()

// Access manager from session
m := sess.Manager()

Session Type Helpers:

// Check if session is a fake player (testing bot)
if sess.IsFake() {
    // Has FakeMarker component
}

// Check if session is an NPC entity
if sess.IsEntity() {
    // Has EntityMarker component
}

// Check if session is any kind of actor (fake or entity)
if sess.IsActor() {
    // Not a real player (no network session)
}

Fake Players & NPCs

PECS supports spawning fake players (bots) and NPC entities that participate in the component system just like real players.

Types:

Type Marker Federated ID Use Case
Real Player None XUID Human players
Fake Player FakeMarker Custom ID Testing bots, lobby filling
NPC Entity EntityMarker None AI entities, shopkeepers

Spawning Bots:

// Configuration for spawning
cfg := pecs.ActorConfig{
    Name:     "TestBot",
    Skin:     someSkin,
    Position: mgl64.Vec3{0, 64, 0},
    Yaw:      0,
    Pitch:    0,
}

// Spawn a fake player (can participate in Peer[T] lookups with fakeID)
sess := mngr.SpawnFake(tx, cfg, "fake-player-123")

// Spawn an NPC entity (local only, no federation)
sess := mngr.SpawnEntity(tx, cfg)

// Add components as usual
pecs.Add(sess, &Health{Current: 100, Max: 100})

Federated ID Resolution:

// Real players: ID() returns XUID
// Fake players: ID() returns FakeMarker.ID
// Entities: ID() returns "" (no federated ID)
id := sess.ID()

// Unified lookup (works for real and fake players)
sess := mngr.GetSessionByID(id)

Marker Components:

// FakeMarker and EntityMarker are empty struct markers
type FakeMarker struct{}
type EntityMarker struct{}

// Check with helper methods
if sess.IsFake() {
    fmt.Println("Fake player ID:", sess.ID())
}

Components

Components are plain Go structs that hold data. No interfaces required.

type Health struct {
    Current int
    Max     int
}

type Frozen struct {
    Until    time.Time
    FrozenBy string
}

type Inventory struct {
    Items []item.Stack
    Gold  int
}

Component Operations:

// Add or replace component
pecs.Add(sess, &Health{Current: 20, Max: 20})

// Check existence
if pecs.Has[Health](sess) {
    // Has health component
}

// Get component (nil if missing)
if health := pecs.Get[Health](sess); health != nil {
    health.Current -= 5
}

// Get or add with default value
health := pecs.GetOrAdd(sess, &Health{Current: 100, Max: 100})

// Remove component
pecs.Remove[Frozen](sess)

Lifecycle Hooks:

Components can implement Attachable and/or Detachable for lifecycle callbacks:

type Tracker struct {
    StartTime time.Time
}

func (t *Tracker) Attach(s *pecs.Session) {
    t.StartTime = time.Now()
    fmt.Println("Tracker attached to", s.Name())
}

func (t *Tracker) Detach(s *pecs.Session) {
    duration := time.Since(t.StartTime)
    fmt.Printf("Player %s tracked for %v\n", s.Name(), duration)
}

Temporary Components:

Components can be added with an expiration time. They are automatically removed when the time passes.

// Add component that expires after duration
pecs.AddFor(sess, &SpeedBoost{Multiplier: 2.0}, 10*time.Second)

// Add component that expires at specific time
pecs.AddUntil(sess, &EventBuff{Bonus: 50}, eventEndTime)

// Check remaining time
remaining := pecs.ExpiresIn[SpeedBoost](sess)
if remaining > 0 {
    fmt.Printf("Speed boost expires in %v\n", remaining)
}

// Get exact expiration time
expireTime := pecs.ExpiresAt[SpeedBoost](sess)

// Check if expired (but not yet removed)
if pecs.Expired[SpeedBoost](sess) {
    // Will be removed next scheduler tick
}

Temporary components respect lifecycle hooks - Detach is called when the component expires.

Systems

Systems contain game logic and declare dependencies via struct tags. PECS automatically injects the required data before execution.

There are three types of systems:

  • Handlers - React to player events
  • Loops - Run at fixed intervals
  • Tasks - One-shot delayed execution

All systems share the same dependency injection model.

Handlers

Handlers respond to events emitted through PECS. They receive dependency injection just like loops and tasks.

type DamageHandler struct {
    Session *pecs.Session
    Health  *Health  `pecs:"mut"`
    GodMode *GodMode `pecs:"opt"`
}

func (h *DamageHandler) HandleHurt(ev *pecs.EventHurt) {
    if h.GodMode != nil {
        *ev.Damage = 0
        return
    }
    h.Health.Current -= int(*ev.Damage)
    if h.Health.Current <= 0 {
        ev.Ctx.Val().Kill(ev.Source)
    }
}

func (h *DamageHandler) HandleDeath(ev *pecs.EventDeath) {
    ev.Player.Message("You died!")
}

// Register with bundle
bundle.Handler(&DamageHandler{})

Built-in Events:

PECS wraps all Dragonfly player events as pooled event types. See event.go for the complete list including EventMove, EventHurt, EventDeath, EventChat, EventQuit, and more.

Custom Events:

Handlers also support custom event types. See Events for details on defining and emitting your own events.

Execution:

Handlers execute synchronously in registration order. All matching handlers complete before Emit() returns.

Global Handlers:

Handlers without a *pecs.Session field or session-scoped components are global handlers. They run once per event instead of once per session. This is useful for logging, analytics, anti-cheat, or any cross-cutting concern that doesn't need per-session state.

// Global handler - runs once per event, not per-session
type ChatLogger struct {
    Manager *pecs.Manager
    Config  *ServerConfig `pecs:"res"`
}

func (h *ChatLogger) HandleChat(ev *pecs.EventChat) {
    // Runs once when any player chats
    log.Printf("[CHAT] %s", *ev.Message)
}

// Session-scoped handler - runs for each matching session
type ChatFilter struct {
    Session *pecs.Session  // Having Session makes this session-scoped
    Muted   *MutedPlayer
}

func (h *ChatFilter) HandleChat(ev *pecs.EventChat) {
    // Runs for the player who sent the message
    ev.Cancel()
}

When using Manager.Emit(), global handlers are invoked first, then session-scoped handlers for each session. Use Manager.EmitGlobal() to invoke only global handlers.

Loops

Loops run at fixed intervals for all sessions that match their component requirements. Loops without a *pecs.Session field or session components are global and run once per interval instead of per-session.

type RegenLoop struct {
    Session *pecs.Session
    Health  *Health `pecs:"mut"`
    Config  *Config `pecs:"res"`
    
    _ pecs.Without[Combat]    // Don't regen while in combat
    _ pecs.Without[Spectator] // Spectators don't regen
}

func (l *RegenLoop) Run(tx *world.Tx) {
    if l.Health.Current < l.Health.Max {
        l.Health.Current += l.Config.RegenRate
        if l.Health.Current > l.Health.Max {
            l.Health.Current = l.Health.Max
        }
    }
}

// Register: runs every second in Default stage
bundle.Loop(&RegenLoop{}, time.Second, pecs.Default)

// Run every tick (interval of 0)
bundle.Loop(&TickLoop{}, 0, pecs.Before)

// Global loop (no session field or component) - runs once per interval
type WorldCleanupLoop struct {
    Manager *pecs.Manager
    Config  *ServerConfig `pecs:"res"`
}

func (l *WorldCleanupLoop) Run(tx *world.Tx) {
    for _, sess := range l.Manager.AllSessions() {
        // Clean up expired data...
    }
}

bundle.Loop(&WorldCleanupLoop{}, time.Minute, pecs.After)

Tasks

Tasks are one-shot systems scheduled for future execution. Tasks without a *pecs.Session field or session components are global and can be scheduled with ScheduleGlobal or DispatchGlobal.

type TeleportTask struct {
    Session *pecs.Session
    
    // Payload fields - set when scheduling
    Destination mgl64.Vec3
    Message     string
}

func (t *TeleportTask) Run(tx *world.Tx) {
    if p, ok := t.Session.Player(tx); ok {
        p.Teleport(t.Destination)
        p.Message(t.Message)
    }
}

// Register task type with bundle (enables pooling optimization)
bundle.Task(&TeleportTask{}, pecs.Default)

// Global task (no session field or component)
type ServerAnnouncementTask struct {
    Manager *pecs.Manager
    Message string
}

func (t *ServerAnnouncementTask) Run(tx *world.Tx) {
    for _, s := range t.Manager.AllSessions() {
        p, _ := s.Player(tx)
        p.Message(t.Message)
    }
}

Scheduling Tasks:

// Schedule for future execution
handle := pecs.Schedule(sess, &TeleportTask{
    Destination: mgl64.Vec3{0, 100, 0},
    Message:     "Welcome to spawn!",
}, 5*time.Second)

// Cancel if needed
handle.Cancel()

// Immediate dispatch (next tick)
pecs.Dispatch(sess, &SomeTask{})

// Schedule at specific time
pecs.ScheduleAt(sess, &DailyRewardTask{}, midnight)

// Repeating task (runs 5 times, every second)
repeatHandle := pecs.ScheduleRepeating(sess, &TickTask{}, time.Second, 5)

// Infinite repeating until cancelled
repeatHandle := pecs.ScheduleRepeating(sess, &HeartbeatTask{}, time.Second, -1)
repeatHandle.Cancel()

// Global tasks (not tied to any session)
pecs.ScheduleGlobal(mngr, &ServerAnnouncementTask{Message: "Restarting!"}, 5*time.Minute)
pecs.DispatchGlobal(mngr, &SomeGlobalTask{})

Multi-Session Tasks:

Tasks can involve two sessions (must be in the same world):

type TradeTask struct {
    Session  *pecs.Session  // First player (buyer)
    Buyer    *Inventory `pecs:"mut"`
    
    Session2 *pecs.Session  // Second player (seller)
    Seller   *Inventory `pecs:"mut"`
    
    // Payload
    Item  item.Stack
    Price int
}

func (t *TradeTask) Run(tx *world.Tx) {
    // Both sessions guaranteed to be valid
    t.Seller.Items = append(t.Seller.Items, t.Item)
    t.Seller.Gold += t.Price
    t.Buyer.Gold -= t.Price
}

// Schedule multi-session task
pecs.Schedule2(buyer, seller, &TradeTask{Item: sword, Price: 100}, time.Second)

Dependency Injection

Tag Reference

Tag Description Example
(none) Required read-only component Health *Health
pecs:"mut" Required mutable component Health *Health \pecs:"mut"``
pecs:"opt" Optional component (nil if missing) Shield *Shield \pecs:"opt"``
pecs:"opt,mut" Optional mutable component Buff *Buff \pecs:"opt,mut"``
pecs:"rel" Relation traversal Target *Health \pecs:"rel"``
pecs:"res" Resource Config *Config \pecs:"res"``
pecs:"res,mut" Mutable resource State *State \pecs:"res,mut"``
pecs:"peer" Peer data resolution Friend *Profile \pecs:"peer"``
pecs:"shared" Shared entity resolution Party *PartyInfo \pecs:"shared"``

Special Fields (auto-injected):

Field Description
Session *pecs.Session Current session
Manager *pecs.Manager Manager instance

Phantom Types

Use phantom types to filter which sessions a system runs on:

type CombatLoop struct {
    Session *pecs.Session
    Health  *Health `pecs:"mut"`
    
    _ pecs.With[InCombat]     // Only run if InCombat component exists
    _ pecs.Without[Spectator] // Skip if Spectator component exists
    _ pecs.Without[Dead]      // Skip if Dead component exists
}

Resources

Resources are global singletons available to all systems across all bundles. Register them with either the builder or a bundle:

type GameConfig struct {
    MaxPartySize  int
    RegenInterval time.Duration
    SpawnPoint    mgl64.Vec3
}

type Database struct {
    conn *sql.DB
}

type Logger struct {
    prefix string
}

// Register globally with builder
mngr := pecs.NewBuilder().
    Resource(&Database{conn: db}).
    Resource(&Logger{prefix: "[PECS]"}).
    Bundle(gameBundle).
    Init()

// Or register with bundle (still globally accessible)
bundle.Resource(&GameConfig{
    MaxPartySize:  5,
    RegenInterval: time.Second,
    SpawnPoint:    mgl64.Vec3{0, 64, 0},
})

// Access in any system
type SaveHandler struct {
    Session *pecs.Session
    DB      *Database   `pecs:"res"`
    Logger  *Logger     `pecs:"res"`
    Config  *GameConfig `pecs:"res"`
}

func (h *SaveHandler) HandleQuit(ev *pecs.EventQuit) {
    h.Logger.Log("Player", ev.Player.Name(), "disconnecting")
    h.DB.SavePlayer(h.Session)
}

func (h *SaveHandler) HandleRespawn(ev *pecs.EventRespawn) {
    *ev.Position = h.Config.SpawnPoint
}

Programmatic Access:

// From session
config := pecs.Resource[GameConfig](sess)

// From manager
db := pecs.ManagerResource[Database](mngr)

Relations

Relations create type-safe links between sessions. PECS automatically cleans up relations when sessions disconnect.

Local Relations

Use Relation[T] and RelationSet[T] for references between players on the same server:

// Single reference
type Following struct {
    Target pecs.Relation[Position]
}

// Multiple references
type PartyLeader struct {
    Name    string
    Members pecs.RelationSet[PartyMember]
}

type PartyMember struct {
    JoinedAt time.Time
    Leader   pecs.Relation[PartyLeader]
}

Using Relations:

// Set a relation
member := &PartyMember{JoinedAt: time.Now()}
member.Leader.Set(leaderSession)
pecs.Add(memberSession, member)

// Get target session
if targetSess := member.Leader.Get(); targetSess != nil {
    // Access target session
}

// Check validity
if member.Leader.Valid() {
    // Target exists and has the required component
}

// Clear relation
member.Leader.Clear()

Using RelationSets:

leader := pecs.Get[PartyLeader](leaderSession)

// Add member
leader.Members.Add(memberSession)

// Remove member
leader.Members.Remove(memberSession)

// Check membership
if leader.Members.Has(memberSession) {
    // Is a member
}

// Get count
count := leader.Members.Len()

// Get all non-closed sessions
for _, memberSess := range leader.Members.All() {
    // Process each member session
}

// Resolve all valid members with their components
for _, resolved := range leader.Members.Resolve() {
    sess := resolved.Session     // *Session
    member := resolved.Component // *PartyMember
    // Process member data
}

// Clear all
leader.Members.Clear()

Relation Resolution

Use the pecs:"rel" tag to automatically resolve relations in systems:

type PartyHealLoop struct {
    Session *pecs.Session
    Member  *PartyMember
    Leader  *PartyLeader `pecs:"rel"` // Resolved from Member.Leader
}

func (l *PartyHealLoop) Run(tx *world.Tx) {
    if l.Leader != nil {
        // Access leader's data
        fmt.Println("Leader:", l.Leader.Name)
    }
}

For relation sets, inject a slice:

type PartyBuffLoop struct {
    Session  *pecs.Session
    Leader   *PartyLeader
    Members  []*PartyMember `pecs:"rel"` // Resolved from Leader.Members
}

func (l *PartyBuffLoop) Run(tx *world.Tx) {
    for _, member := range l.Members {
        // Apply buff to each member
    }
}

Manual Resolution:

// Resolve relation to get session and component
if sess, comp, ok := member.Leader.Resolve(); ok {
    fmt.Println("Leader name:", comp.Name)
}

Federation

Federation enables cross-server data access. When players can be on different servers (in a network), you need a way to access their data regardless of which server they're on.

Federation Overview

PECS provides two reference types for cross-server data:

Type Purpose Target Example
Peer[T] Reference another player's data Player (by ID) Friend, party member
Shared[T] Reference shared entity data Entity (by ID) Party, guild, match

Data is fetched via Providers - interfaces you implement to connect PECS to your backend services.

Peer References

Peer[T] references another player's component data. Works whether the player is local or remote.

// Component with peer reference
type FriendsList struct {
    BestFriend Peer[FriendProfile]   // Single friend
    Friends    PeerSet[FriendProfile] // Multiple friends
}

type FriendProfile struct {
    Username string
    Online   bool
    Server   string
}

Using Peer:

// Set peer by player ID (e.g., XUID)
friends := &FriendsList{}
friends.BestFriend.Set("player-123-xuid")
pecs.Add(sess, friends)

// Get ID
id := friends.BestFriend.ID()

// Check if set
if friends.BestFriend.IsSet() {
    // Has a best friend set
}

// Clear
friends.BestFriend.Clear()

// Manual resolution (useful in commands/forms)
if profile, ok := friends.BestFriend.Resolve(sess.Manager()); ok {
    fmt.Println("Best friend:", profile.Username)
}

Using PeerSet:

// Set all IDs
friends.Friends.Set([]string{"player-1", "player-2", "player-3"})

// Add single
friends.Friends.Add("player-4")

// Remove
friends.Friends.Remove("player-2")

// Get all IDs
ids := friends.Friends.IDs()

// Get count
count := friends.Friends.Len()

// Clear all
friends.Friends.Clear()

// Manual resolution (useful in commands/forms)
profiles := friends.Friends.Resolve(sess.Manager())
for _, profile := range profiles {
    fmt.Println("Friend:", profile.Username)
}

Resolving Peer Data in Systems:

type FriendsDisplayLoop struct {
    Session     *pecs.Session
    FriendsList *FriendsList
    
    // PECS resolves these automatically via providers
    BestFriend  *FriendProfile   `pecs:"peer"` // From FriendsList.BestFriend
    AllFriends  []*FriendProfile `pecs:"peer"` // From FriendsList.Friends
}

func (l *FriendsDisplayLoop) Run(tx *world.Tx) {
    p, _ := l.Session.Player(tx)
    
    if l.BestFriend != nil {
        status := "offline"
        if l.BestFriend.Online {
            status = "online on " + l.BestFriend.Server
        }
        p.Message("Best friend: " + l.BestFriend.Username + " (" + status + ")")
    }
    
    for _, friend := range l.AllFriends {
        // Display friend info
    }
}

Shared References

Shared[T] references shared entities (parties, guilds, matches) that aren't tied to a specific player.

// Component with shared reference
type MatchmakingData struct {
    CurrentParty Shared[PartyInfo]
    ActiveMatch  Shared[MatchInfo]
}

type PartyInfo struct {
    ID       string
    LeaderID string
    Members  []PartyMemberInfo
    Open     bool
}

type PartyMemberInfo struct {
    ID       string
    Username string
}

Using Shared:

mmData := &MatchmakingData{}
mmData.CurrentParty.Set("party-456")
pecs.Add(sess, mmData)

// Same API as Peer
id := mmData.CurrentParty.ID()
mmData.CurrentParty.Clear()

// Manual resolution (useful in commands/forms)
if party, ok := mmData.CurrentParty.Resolve(sess.Manager()); ok {
    fmt.Println("Party:", party.ID, "Members:", len(party.Members))
}

Using SharedSet:

type GuildData struct {
    ActiveWars SharedSet[WarInfo]
}

// Same API as PeerSet
guildData.ActiveWars.Set([]string{"war-1", "war-2"})
guildData.ActiveWars.Add("war-3")

// Manual resolution
wars := guildData.ActiveWars.Resolve(sess.Manager())
for _, war := range wars {
    fmt.Println("War:", war.ID)
}

Resolving Shared Data in Systems:

type PartyDisplayHandler struct {
    Session *pecs.Session
    MMData  *MatchmakingData
    Party   *PartyInfo `pecs:"shared"` // Resolved from MMData.CurrentParty
}

func (h *PartyDisplayHandler) HandleJoin(ev *pecs.EventJoin) {
    if h.Party != nil {
        ev.Player.Message("You're in party: " + h.Party.ID)
        ev.Player.Message("Leader: " + h.Party.LeaderID)
        ev.Player.Message("Members: " + strconv.Itoa(len(h.Party.Members)))
    }
}

Providers

Providers fetch and sync data from your backend services. Implement PeerProvider for peer data and SharedProvider for shared data.

PeerProvider:

When a player joins, PECS automatically queries all registered `PeerProvider`s. If data is returned,
components are added to the session and kept in sync via subscriptions. This removes the need for
manual data fetching handlers.

You can mark a provider as required using `pecs.WithRequired(true)`. If a required provider fails to
fetch data during session creation, `NewSession` will return an error.

type PeerProvider interface {
    // Unique name for logging
    Name() string
    
    // Component types this provider handles
    PlayerComponents() []reflect.Type
    
    // Fetch single player's components
    FetchPlayer(ctx context.Context, playerID string) ([]any, error)
    
    // Batch fetch multiple players
    FetchPlayers(ctx context.Context, playerIDs []string) (map[string][]any, error)
    
    // Subscribe to real-time updates
    SubscribePlayer(ctx context.Context, playerID string, updates chan<- PlayerUpdate) (Subscription, error)
}

Example PeerProvider Implementation:

type StatusProvider struct {
    statusClient statusv1.StatusServiceClient
    nats         *nats.Conn
}

func (p *StatusProvider) Name() string {
    return "status"
}

func (p *StatusProvider) PlayerComponents() []reflect.Type {
    return []reflect.Type{reflect.TypeOf(FriendProfile{})}
}

func (p *StatusProvider) FetchPlayer(ctx context.Context, playerID string) ([]any, error) {
    resp, err := p.statusClient.GetStatus(ctx, &statusv1.GetStatusRequest{PlayerId: playerID})
    if err != nil {
        return nil, err
    }
    
    return []any{
        &FriendProfile{
            Username: resp.Username,
            Online:   resp.Online,
            Server:   resp.GetServerId(),
        },
    }, nil
}

func (p *StatusProvider) FetchPlayers(ctx context.Context, playerIDs []string) (map[string][]any, error) {
    resp, err := p.statusClient.GetStatuses(ctx, &statusv1.GetStatusesRequest{PlayerIds: playerIDs})
    if err != nil {
        return nil, err
    }
    
    result := make(map[string][]any)
    for id, status := range resp.Statuses {
        result[id] = []any{
            &FriendProfile{
                Username: status.Username,
                Online:   status.Online,
                Server:   status.GetServerId(),
            },
        }
    }
    return result, nil
}

func (p *StatusProvider) SubscribePlayer(ctx context.Context, playerID string, updates chan<- pecs.PlayerUpdate) (pecs.Subscription, error) {
    sub, err := p.nats.Subscribe("status.player."+playerID, func(msg *nats.Msg) {
        // Parse event and send update
        var event statusv1.StatusChanged
        proto.Unmarshal(msg.Data, &event)
        
        updates <- pecs.PlayerUpdate{
            ComponentType: reflect.TypeOf(FriendProfile{}),
            Data: &FriendProfile{
                Username: event.Username,
                Online:   event.Online,
                Server:   event.ServerId,
            },
        }
    })
    if err != nil {
        return nil, err
    }
    
    return &natsSubscription{sub}, nil
}

SharedProvider:

type SharedProvider interface {
    Name() string
    EntityComponents() []reflect.Type
    FetchEntity(ctx context.Context, entityID string) (any, error)
    FetchEntities(ctx context.Context, entityIDs []string) (map[string]any, error)
    SubscribeEntity(ctx context.Context, entityID string, updates chan<- any) (Subscription, error)
}

Registering Providers:

mngr := pecs.NewBuilder().
    Bundle(gameBundle).
    PeerProvider(&StatusProvider{...}).
    PeerProvider(&ProfileProvider{...}).
    SharedProvider(&PartyProvider{...}).
    SharedProvider(&MatchProvider{...}).
    Init()

// Or with options
mngr := pecs.NewBuilder().
    PeerProvider(&StatusProvider{...}, 
        pecs.WithFetchTimeout(2*time.Second),
        pecs.WithGracePeriod(time.Minute),
        pecs.WithStaleTimeout(5*time.Minute),
    ).
    Init()

When to Use Each Type

Type Use When Example
Relation[T] Target must be on same server Combat target, follow target
Peer[T] Target is a player (local or remote) Friend, party member, enemy
Shared[T] Target is a shared entity (not a player) Party, guild, match, server

Decision Flow:

Is the target a player?
├── YES: Could they be on a different server?
│   ├── YES → Peer[T]
│   └── NO (guaranteed same server) → Relation[T]
└── NO (party, guild, match, etc.) → Shared[T]

Events

Emit custom events to handler systems.

Define Events:

type DamageEvent struct {
    Amount int
    Source world.DamageSource
}

type LevelUpEvent struct {
    NewLevel int
    OldLevel int
}

Handle Events:

type NotificationHandler struct {
    Session *pecs.Session
}

// Method names don't matter - matching is done by event type
func (h *NotificationHandler) HandleDamage(e *DamageEvent) {
    if p, ok := h.Session.Player(nil); ok {
        p.Message(fmt.Sprintf("Took %d damage!", e.Amount))
    }
}

func (h *NotificationHandler) HandleLevelUp(e *LevelUpEvent) {
    if p, ok := h.Session.Player(nil); ok {
        p.Message(fmt.Sprintf("Level up! %d -> %d", e.OldLevel, e.NewLevel))
    }
}

Emit Events:

// To single session
sess.Emit(&DamageEvent{Amount: 5, Source: src})

// To all sessions
mngr.Emit(&LevelUpEvent{NewLevel: 10, OldLevel: 9})

// To all except some
mngr.EmitExcept(&ChatEvent{Message: "Hello"}, sender)

Built-in Events:

// Emitted when a component is added
type ComponentAttachEvent struct {
    ComponentType reflect.Type
}

// Emitted when a component is removed
type ComponentDetachEvent struct {
    ComponentType reflect.Type
}

Commands & Forms

Helper functions for Dragonfly commands and forms:

type HealCommand struct {
    Amount int `cmd:"amount"`
}

func (c HealCommand) Run(src cmd.Source, out *cmd.Output, tx *world.Tx) {
    p, sess := pecs.Command(src)
    if sess == nil {
        out.Error("Player-only command")
        return
    }
    
    health := pecs.Get[Health](sess)
    if health == nil {
        out.Error("You don't have health!")
        return
    }
    
    health.Current = min(health.Current+c.Amount, health.Max)
    out.Printf("Healed %d HP!", c.Amount)
}

// Register command with bundle
bundle.Command(cmd.New("heal", "Heal yourself", nil, HealCommand{}))

Forms:

type SettingsForm struct {
    EnableNotifications bool
    Volume              int
}

func (f SettingsForm) Submit(sub form.Submitter, tx *world.Tx) {
    p, sess := pecs.Form(sub)
    if sess == nil {
        return
    }
    
    settings := pecs.GetOrAdd(sess, &Settings{})
    settings.Notifications = f.EnableNotifications
    settings.Volume = f.Volume
}

Bundle Organization

Structure your game with multiple bundles. Bundle names are used in panic/error messages for easier debugging.

func main() {
    core := pecs.NewBundle("core").
        Resource(&ServerConfig{}).
        Handler(&JoinHandler{}).
        Handler(&QuitHandler{}).
        Loop(&AutoSaveLoop{}, time.Minute, pecs.After).
        Build()

    combat := pecs.NewBundle("combat").
        Resource(&CombatConfig{}).
        Handler(&DamageHandler{}).
        Handler(&DeathHandler{}).
        Loop(&CombatTagLoop{}, time.Second, pecs.Default).
        Task(&RespawnTask{}, pecs.Default).
        Build()

    party := pecs.NewBundle("party").
        Resource(&PartyConfig{}).
        Handler(&PartyInviteHandler{}).
        Handler(&PartyChatHandler{}).
        Command(cmd.New("party", "Party commands", nil, PartyCommand{})).
        Build()

    economy := pecs.NewBundle("economy").
        Resource(&EconomyConfig{}).
        Handler(&ShopHandler{}).
        Command(cmd.New("balance", "Check balance", nil, BalanceCommand{})).
        Command(cmd.New("pay", "Pay another player", nil, PayCommand{})).
        Build()

    mngr := pecs.NewBuilder().
        Resource(&Database{}).
        Resource(&Logger{}).
        Bundle(core).
        Bundle(combat).
        Bundle(party).
        Bundle(economy).
        PeerProvider(&StatusProvider{}).
        SharedProvider(&PartyProvider{}).
        Init()
}

Execution Model

Stages

Control execution order with three stages:

const (
    pecs.Before  // Runs first - input handling, pre-processing
    pecs.Default // Runs second - main game logic
    pecs.After   // Runs last - cleanup, synchronization, rendering
)

bundle.Loop(&InputHandler{}, 0, pecs.Before)
bundle.Loop(&GameLogic{}, 0, pecs.Default)
bundle.Loop(&NetworkSync{}, 0, pecs.After)

Parallelism

PECS automatically parallelizes non-conflicting systems:

  • Systems in different stages run sequentially (Before → Default → After)
  • Systems in the same stage that access different components run in parallel
  • Systems that write to the same component type are serialized

The scheduler analyzes component access patterns via tags:

  • Read access (Health *Health) doesn't conflict with other reads
  • Write access (Health *Health \pecs:"mut"``) conflicts with any other access to that component

Transaction Context

All systems receive a *world.Tx parameter. Your session's player is guaranteed to be valid in this transaction.

func (l *MyLoop) Run(tx *world.Tx) {
    // Your session's player - always valid
    p, _ := l.Session.Player(tx)
    p.Message("Hello!")
    
    // Other players via relations - check these
    for _, memberSess := range l.Members {
        if member, ok := memberSess.Player(tx); ok {
            member.Message("Party message")
        }
    }
}

Concurrency

Context Safe Operations
Handlers Read/write components directly
Loops Read/write components directly
Tasks Read/write components directly
Commands Read/write components directly
Forms Read/write components directly
External goroutines Must use sess.Exec()

Critical Rule: Never call sess.Exec() from within a handler, loop, task, command, or form when targeting a session in the same world. This causes deadlock. Use the existing transaction instead.

// WRONG - potential deadlock
func (h *MyHandler) HandleChat(ev *pecs.EventChat) {
    otherSess.Exec(func(tx *world.Tx, p *player.Player) { // DEADLOCK!
        p.Message(*ev.Message)
    })
}

// CORRECT - use existing transaction context
func (h *MyHandler) HandleChat(ev *pecs.EventChat) {
    if other, ok := otherSess.Player(ev.Ctx.Val().Tx()); ok {
        other.Message(*ev.Message)
    }
}

API Reference

Manager

// Session management
mngr.NewSession(p *player.Player) (*Session, error)
mngr.GetSession(p *player.Player) *Session
mngr.GetSessionByUUID(id uuid.UUID) *Session
mngr.GetSessionByName(name string) *Session
mngr.GetSessionByID(id string) *Session
mngr.GetSessionByHandle(h *world.EntityHandle) *Session
mngr.AllSessions() []*Session
mngr.AllSessionsInWorld(w *world.World) []*Session
mngr.SessionCount() int

// Events
mngr.Emit(event any)
mngr.EmitExcept(event any, exclude ...*Session)
mngr.EmitGlobal(event any)

// Federation
mngr.RegisterPeerProvider(p PeerProvider, opts ...ProviderOption)
mngr.RegisterSharedProvider(p SharedProvider, opts ...ProviderOption)

// Lifecycle
mngr.Start()
mngr.Shutdown()
mngr.TickNumber() uint64

// Spawn
mngr.SpawnFake(tx *world.Tx, cfg ActorConfig, fakeID string) *Session
mngr.SpawnEntity(tx *world.Tx, cfg ActorConfig) *Session

Session

sess.Handle() *world.EntityHandle
sess.UUID() uuid.UUID
sess.Name() string
sess.XUID() string
sess.ID() string
sess.Player(tx *world.Tx) (*player.Player, bool)
sess.Exec(fn func(tx *world.Tx, p *player.Player)) bool
sess.World() *world.World
sess.Manager() *Manager
sess.Closed() bool
sess.Mask() Bitmask

// Type checks
sess.IsFake() bool
sess.IsEntity() bool
sess.IsActor() bool

// Events
sess.Emit(event any)

Components

pecs.Add[T any](s *Session, component *T)
pecs.AddFor[T any](s *Session, component *T, duration time.Duration)
pecs.AddUntil[T any](s *Session, component *T, expireAt time.Time)
pecs.Remove[T any](s *Session)
pecs.Get[T any](s *Session) *T
pecs.GetOrAdd[T any](s *Session, defaultVal *T) *T
pecs.Has[T any](s *Session) bool
pecs.ExpiresIn[T any](s *Session) time.Duration
pecs.ExpiresAt[T any](s *Session) time.Time
pecs.Expired[T any](s *Session) bool

Tasks

pecs.Schedule(s *Session, task Runnable, delay time.Duration) *TaskHandle
pecs.Schedule2(s1, s2 *Session, task Runnable, delay time.Duration) *TaskHandle
pecs.ScheduleAt(s *Session, task Runnable, at time.Time) *TaskHandle
pecs.ScheduleRepeating(s *Session, task Runnable, interval time.Duration, times int) *RepeatingTaskHandle
pecs.ScheduleGlobal(m *Manager, task Runnable, delay time.Duration) *TaskHandle
pecs.Dispatch(s *Session, task Runnable) *TaskHandle
pecs.Dispatch2(s1, s2 *Session, task Runnable) *TaskHandle
pecs.DispatchGlobal(m *Manager, task Runnable) *TaskHandle

handle.Cancel()
repeatHandle.Cancel()

Relations

// Relation[T]
relation.Set(target *Session)
relation.Get() *Session
relation.Clear()
relation.Valid() bool
relation.Resolve() (sess *Session, comp *T, ok bool)
relation.TargetType() reflect.Type

// RelationSet[T]
set.Add(target *Session)
set.Remove(target *Session)
set.Has(target *Session) bool
set.Clear()
set.Len() int
set.All() []*Session
set.Resolve() []Resolved[T]
set.TargetType() reflect.Type

Peer & Shared

// Peer[T]
peer.Set(playerID string)
peer.ID() string
peer.IsSet() bool
peer.Clear()
peer.Resolve(m *Manager) (*T, bool)
peer.TargetType() reflect.Type

// PeerSet[T]
peerSet.Set(playerIDs []string)
peerSet.Add(playerID string)
peerSet.Remove(playerID string)
peerSet.IDs() []string
peerSet.Len() int
peerSet.Clear()
peerSet.Resolve(m *Manager) []*T
peerSet.TargetType() reflect.Type

// Shared[T]
shared.Set(entityID string)
shared.ID() string
shared.IsSet() bool
shared.Clear()
shared.Resolve(m *Manager) (*T, bool)
shared.TargetType() reflect.Type

// SharedSet[T]
sharedSet.Set(entityIDs []string)
sharedSet.Add(entityID string)
sharedSet.Remove(entityID string)
sharedSet.IDs() []string
sharedSet.Len() int
sharedSet.Clear()
sharedSet.Resolve(m *Manager) []*T
sharedSet.TargetType() reflect.Type

Helpers

pecs.Command(src cmd.Source) (*player.Player, *Session)
pecs.Form(sub form.Submitter) (*player.Player, *Session)
pecs.Item(user item.User) (*player.Player, *Session)
pecs.NewHandler(sess *Session, p *player.Player) Handler
  • pecs.Resource[T](sess): Retrieve a global resource from a session.
  • pecs.ManagerResource[T](mngr): Retrieve a global resource from a manager.

Builder

pecs.NewBuilder() *Builder

builder.Bundle(callback func(*Manager) *Bundle) *Builder
builder.Resource(res any) *Builder
builder.Handler(h Handler) *Builder
builder.Loop(sys Runnable, interval time.Duration, stage Stage) *Builder
builder.Task(sys Runnable, stage Stage) *Builder
builder.Command(command cmd.Command) *Builder
builder.PeerProvider(p PeerProvider, opts ...ProviderOption) *Builder
builder.SharedProvider(p SharedProvider, opts ...ProviderOption) *Builder
builder.Init() *Manager

Bundle

pecs.NewBundle(name string) *Bundle

bundle.Name() string
bundle.Resource(res any) *Bundle
bundle.Handler(h Handler) *Bundle
bundle.Loop(sys Runnable, interval time.Duration, stage Stage) *Bundle
bundle.Task(sys Runnable, stage Stage) *Bundle
bundle.Command(command cmd.Command) *Bundle
bundle.Build() func(*Manager) *Bundle

Provider Options

pecs.WithFetchTimeout(d time.Duration) ProviderOption   // Default: 5s
pecs.WithGracePeriod(d time.Duration) ProviderOption    // Default: 30s
pecs.WithStaleTimeout(d time.Duration) ProviderOption   // Default: 5m

Acknowledgements

This work is inspired by andreashgk/peex.

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •  

Languages