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.
- Installation
- Quick Start
- Core Concepts
- Dependency Injection
- Relations
- Federation
- Events
- Commands & Forms
- Bundle Organization
- Execution Model
- Concurrency
- API Reference
- Acknowledgements
go get github.com/oriumgames/pecspackage 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))
}
}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)
}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 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 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 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 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 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)| 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 |
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 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 create type-safe links between sessions. PECS automatically cleans up relations when sessions disconnect.
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()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 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.
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[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[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 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()| 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]
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
}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
}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()
}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)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
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")
}
}
}| 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)
}
}// 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) *Sessionsess.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)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) boolpecs.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()// 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[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.Typepecs.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) Handlerpecs.Resource[T](sess): Retrieve a global resource from a session.pecs.ManagerResource[T](mngr): Retrieve a global resource from a manager.
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() *Managerpecs.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) *Bundlepecs.WithFetchTimeout(d time.Duration) ProviderOption // Default: 5s
pecs.WithGracePeriod(d time.Duration) ProviderOption // Default: 30s
pecs.WithStaleTimeout(d time.Duration) ProviderOption // Default: 5mThis work is inspired by andreashgk/peex.